Skip to content
Folio

Overlays

Tooltip

A short label on hover and on focus.

Usage

When to reach for it

Never for anything the reader NEEDS: a tooltip is unreachable on touch and invisible while scanning.
TSX
import { Tooltip } from '@misoto22/design'

Notes

A short label on hover and on focus.

asChild on the trigger by design: the tooltip must not add a wrapper that swallows the trigger's own focus ring or breaks a flex row. It also means the child has to be focusable — a <div> trigger gets no keyboard tooltip, which is the failure this API shape makes obvious rather than silent.

Not a replacement for an accessible name. An icon-only button still needs its own aria-label; the tooltip repeats that name for sighted pointer users.

Anatomy

Tooltip anatomy
ElementDescription
ProviderrequiredTooltipProvider, wrapped once around the app or the smallest subtree that has tooltips. It holds the shared 700ms open delay and the 300ms skip window; Radix throws without it rather than rendering an untimed tip.
Triggerrequiredchildren, handed to Radix with asChild — so the child IS the trigger and no wrapper is inserted around it.
Tiprequiredcontent, in the portalled panel: 11px mono on --feature-surface, capped at 16rem, 6px off the chosen side, at --z-toast (300) so it stays above a modal it was opened inside.
PortalWhere the tip lands — document.body, or the element an enclosing OverlayContainer names, which is also the box it collides against with 8px of padding.

Best practices

Do

  • Put the Provider high and put one there. Radix throws without it, and a provider per tooltip defeats the shared timing that stops a row of icon buttons flashing a separate tip on every hover.
  • Match the tip to the control’s aria-label word for word: two different names for one control is the “label in name” failure (WCAG 2.5.3), and a voice-control user says the words they can see.
  • Keep the tip to a phrase. It is capped at 16rem and set in 11px mono, so a sentence wraps into a five-line block that covers the thing it was describing.
  • Put it on a control that already works without it: Radix returns early when the pointer type is touch, so the tip never opens on a phone at all and anything it is the sole carrier of is simply missing there.

Don’t

  • delayDuration={0} does not just make it faster — the state becomes instant-open rather than delayed-open, and the fade is keyed to delayed-open, so the tip appears with no transition at all.
  • Setting delayDuration on one Tooltip overrides the provider for that trigger alone, which is how a toolbar ends up with one tip that appears instantly beside neighbours at 700ms — read as lag, not as emphasis.
  • Nothing focusable belongs in content: the tip is not in the tab order and closes when the trigger loses focus, so a link or a button in there is reachable by pointer and by nothing else. That is a Popover.

Examples

an icon button

A tip on a control that carries a glyph and no word. The tip text, the icon and the button's own accessible name all change together, because a copy control that looks identical before and after leaves the reader clicking it twice to be sure. Match the tip to the aria-label word for word: two different names for one control is the label-in-name failure (WCAG 2.5.3), and a voice-control user says the words they can see. The tip is not the name, either — Radix returns early on a touch pointer, so on a phone it never opens at all and an icon button without its own aria-label is unnamed.

one provider

One Provider around the whole toolbar, which is where it belongs: it holds the shared 700ms open delay and the 300ms skip window, so moving along the row opens the next tip immediately instead of flashing a separate one at every button. Radix throws without a Provider, and a Provider per tooltip throws away exactly that shared timing. Do not set delayDuration on a single Tooltip to speed one up — it overrides the provider for that trigger alone, and one instant tip beside neighbours at 700ms reads as lag rather than as emphasis. Zero is worse still: the state becomes instant-open, and the fade is keyed to delayed-open, so the tip appears with no transition at all.

a shortcut hint

The control says what it does on the page; the tip only adds the shortcut. A tooltip is unreachable on touch, invisible to anyone scanning, and dismissed the moment focus leaves — so nothing a reader NEEDS can live only in one. Two more limits worth knowing: the tip is capped at 16rem in 11px mono, so a sentence wraps into a five-line block that covers the thing it describes; and nothing focusable belongs inside content, because the tip is not in the tab order and closes when the trigger loses focus. A link or a button in there is reachable by pointer and by nothing else. That is a Popover.

The same keys are printed on the page, so S is not news a hover has to break.

Re-exports

TooltipProvider = TooltipPrimitive.Provider

Wrap the app — or the smallest subtree that has tooltips — once. Radix needs it to share the open/close timing between neighbouring triggers, which is what stops a row of icon buttons flashing a tooltip per hover.

Keyboard

Tooltip keyboard interactions
KeyDoes
TabShows the tip — focus reveals it, not only hover.
EscapeDismisses it.

Accessibility

  • Inside a bounded frame — a device preview, an embedded console — wrap the subtree in `<OverlayContainer container={el}>`. The panel then renders into that element and collides with its edges instead of the viewport’s, and inherits the `dir` and `data-density` set there.
  • The trigger is asChild, so the child must be focusable — a div trigger simply has no keyboard tooltip, which this API shape makes obvious rather than silent.
  • Not an accessible name. An icon-only button still needs its own aria-label.