Overlays
Tooltip
A short label on hover and on focus.
Usage
When to reach for it
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
| Element | Description |
|---|---|
| Providerrequired | TooltipProvider, 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. |
| Triggerrequired | children, handed to Radix with asChild — so the child IS the trigger and no wrapper is inserted around it. |
| Tiprequired | content, 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. |
| Portal | Where 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.ProviderWrap 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
| Key | Does |
|---|---|
| Tab | Shows the tip — focus reveals it, not only hover. |
| Escape | Dismisses 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.