Skip to content
Navigation

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 no aria-describedby — deliberately — and its docs say the trigger “must have an aria-label attribute 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 and aria-describedby on 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:

  1. Use aria-disabled="true" plus opacity-50 rather than the native disabled attribute, so the control stays focusable and hoverable. [a11y]
  2. 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 disabled control — 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-describedby on a React tooltip to match Angular. Deliberate; see above.