Tooltip
Short supplementary hint on hover or focus. Never the only place essential information lives — for that use inline text or a Popover.
Added 14 October 2026
Tooltip, inline text or Popover?
- Tooltip — a short label for something already visible: an icon button’s name, a truncated value in full, a one-line “why is this unavailable”. Disappears on blur, can’t be selected, isn’t reachable by touch.
- Inline text — anything the user needs to complete a task. A business rule (“budgets lock once the campaign goes live”) belongs under the field, not behind a hover.
- Popover — content that is long, interactive, or worth keeping open: links, several lines, a form.
If you find yourself writing two sentences into a tooltip, it is the wrong component.
Decided: a tooltip is a NAME, never a description
The two upstreams model tooltips differently, and this library has picked a side rather than splitting the difference (decided August 2026):
- Base UI (React) treats the tooltip as the trigger’s name rendered visually. It sets
no
role="tooltip"and noaria-describedby— deliberately — and its docs say the trigger “must have anaria-labelattribute that closely matches the tooltip’s content”. - brain (Angular) treats it as a description on top of a trigger that is already named:
it puts
role="tooltip"on the content andaria-describedbyon the trigger while open.
You cannot have both: a trigger with aria-label="Help" and a describedby pointing at
“Help” announces as “Help, button, Help”.
Our rule: the tooltip’s content is the trigger’s accessible name. For a trigger with no visible text
— an icon button — give it an aria-label matching the tooltip text, in both frameworks. Angular’s extra
describedby then duplicates the name: cosmetic redundancy in one framework, not a barrier.
Never put an aria-label on a trigger that already has visible text. Overriding “top” with
“Add to library” makes the accessible name stop containing the visible label, which is a WCAG 2.5.3
Label in Name failure: a voice-control user saying “click top” gets nothing. If a trigger has its own visible
label, the tooltip must not restate it (see Never below) — and if the tooltip would say something different,
that is a description, which by the rule above is not a tooltip at all.
The corollary matters more: if the content is a description — a reason, a rule, an
explanation the user needs — it is not a tooltip at all. Base UI says this outright: “If the
description is important to understanding the element, don’t hide it behind a tooltip.” Put it
in inline text, or a Popover on click:
<Popover>
<PopoverTrigger render={<Button variant="outline" aria-disabled className="opacity-50" />}>Publish</PopoverTrigger>
<PopoverContent>Add a creative before publishing.</PopoverContent>
</Popover>
Use click, not hover. Base UI’s Popover has openOnHover; brain’s has no hover trigger at
all, so a hover-opened popover would be React-only — and click is the better answer anyway,
since it works for touch and keyboard.
Applying the rule in React, where nothing is wired for you:
<Tooltip>
<TooltipTrigger render={<Button variant="ghost" size="icon" aria-label="Help" />}>
<CircleHelpIcon />
</TooltipTrigger>
<TooltipContent>Help</TooltipContent>
</Tooltip>
Do this for every icon-only trigger in both frameworks — lucide (aria-hidden on
icons without children) and @ng-icons (aria-hidden unless overridden) both hide the
glyph from assistive tech, so without a label the control announces as bare “button”. [a11y]
Disabled triggers: the tooltip never opens
A native disabled button fires no pointer events and leaves the tab order, and this
library’s Button additionally sets disabled:pointer-events-none /
data-disabled:pointer-events-none. A tooltip on a truly disabled trigger therefore never
opens in either framework — which is exactly the case where users most need to know why.
This is the most common tooltip mistake, and by the rule above it has two parts:
- Use
aria-disabled="true"plusopacity-50rather than the nativedisabledattribute, so the control stays focusable and hoverable. [a11y] - Put the reason in a
Popover(or inline), not the tooltip — a reason is a description.
Both together, not either alone: aria-disabled on its own just makes an inaccessible reason
hoverable.
Composition differs; the visual result does not
React composes three parts; Angular takes the text as an input on the trigger and brain
renders the content into a CDK overlay. Both examples use an icon-only trigger, because that is the shape the
rule above sanctions — the tooltip text is the name, so it also goes in aria-label:
<Tooltip>
<TooltipTrigger render={<Button variant="outline" size="icon" aria-label="Add to library" />}>
<PlusIcon />
</TooltipTrigger>
<TooltipContent side="top">Add to library</TooltipContent>
</Tooltip>
<button hlmBtn variant="outline" size="icon" aria-label="Add to library" hlmTooltip="Add to library" position="top">
<ng-icon name="lucidePlus" />
</button>
Known cross-framework gaps
Deliberate or upstream-imposed, all worth knowing before you design around them:
| React | Angular | |
|---|---|---|
| Open / close delay | 150ms / 100ms | 150ms / 100ms (brain defaults) |
| Placement | side + align + offsets, incl. logical inline-start/inline-end |
position: only top/bottom/left/right, no align |
| Gap to trigger | sideOffset, default 4px |
fixed 8px, not settable |
| Escape dismisses | yes | no — brain binds no keydown |
| Open/close event | onOpenChange |
none exposed |
data-slot="tooltip-content" |
yes | no — brain’s content takes classes only |
Two of these are real defects we cannot fix from the wrapper: Angular tooltips cannot be
dismissed with Escape (WCAG 1.4.13 asks for dismissible hover content, and brain 1.0.1
exposes no public hide — mutableTooltipDisabled only guards the next open), and the
arrow is pinned to the popup’s centre in Angular where React’s tracks the anchor after a
viewport flip. Both are upstream issues, not app-side workarounds.
Note the role/aria-describedby row is absent from that table on purpose: it is not a gap to
close but the decision at the top of this page. We do not intend to wire Base UI’s tooltip for
describedby; doing so would mean generating an id and tracking open state that Base UI
deliberately does not expose, to enable a content pattern we have ruled out.
Also note TooltipProvider is currently inert: Tooltip nests its own, so an app-level
provider is shadowed and Base UI’s skip-delay grouping (“once one tooltip is open, adjacent
ones open instantly”) never engages. Don’t build on the grouping behaviour yet.
The test
Before reaching for a tooltip: if the tooltip never opened, would anything be lost? If yes, it is not a tooltip. That is why “un-truncate this value” and “name this icon button” pass, and “explain why this is disabled” does not.
Never
- Essential information behind a hover. It is invisible to touch, unselectable, and gone on blur.
- A tooltip on a
disabledcontrol — it will never appear. - An icon-only trigger with no
aria-label. - A tooltip that repeats an already-visible label verbatim — noise in a screen reader.
- Hand-wiring
aria-describedbyon a React tooltip to match Angular. Deliberate; see above.