Tooltip
Short hover and keyboard-focus hint anchored to a control. Composable trigger and content on Base UI, role=tooltip, 4 sides × 3 alignments with collision flipping, 5 intent plates, 3 sizes, an optional arrow, grouped delays via a provider, and a label/description ARIA switch.
Playground
A tooltip never appears on touch. Base UI opens it on hover with
mouseOnly, and on focus only under keyboard modality — so a phone or tablet
user sees nothing, and there is no long-press fallback. Anything a touch user
must be able to read cannot live in a tooltip: put it in visible text, in the
control's aria-label, or behind a Popover, which
opens on tap.
Installation
pnpm add @tessinaui/uiUsage
import { Tooltip, TooltipTrigger, TooltipContent } from "@tessinaui/ui";<Tooltip>
<TooltipTrigger render={<Button variant="outline">Save</Button>} />
<TooltipContent>Saves your draft without publishing it</TooltipContent>
</Tooltip>TooltipTrigger renders a <button> by default; render swaps in any element
and keeps the wiring. TooltipContent collapses Base UI's portal, positioner
and popup into one part, and draws the arrow for you unless you pass
arrow={false}.
Rules
These are house rules because the design systems we surveyed agree on them, not because of a local preference.
- Nothing interactive inside. No links, no buttons, nothing focusable — a tooltip closes as the pointer leaves the trigger, so anything in it is a trap. Use Popover for content people need to reach.
- Nothing essential inside. A tooltip is hidden by default, absent on touch, and easy to miss. If a task cannot be completed without the text, it belongs in helper text or a label.
- The trigger must be focusable, so the tip opens for a keyboard user too.
- Keep it short — a phrase or a sentence, sentence case.
- Don't put a tooltip on a disabled control if you can avoid it. See Disabled triggers for the case where you cannot.
Which component?
| You want | Use |
|---|---|
| A short hint on hover/focus | Tooltip |
| Content people tap, click or interact with | Popover |
| A rich preview of an entity on hover | HoverCard |
| A tour step, coach mark or feature callout | CoachMark |
| A value readout on a chart | Chart's own ChartTooltip |
Examples
Default
Sides
side picks the edge; the tip flips automatically when it would collide with
the viewport. (Flipping is switched off in this example so all four stay put.)
Alignment
align slides the tip along the chosen side.
Intents
Five plates — none is the always-dark overlay, and the four semantic values
paint the matching surface. The arrow reads the plate's own published fill, so
it can never drift off the tip.
Delay
delay and closeDelay are separate, and both are inherited by the trigger.
Grouped delays
Wrap a cluster in TooltipProvider: the first tip waits out the delay, then
every neighbour you sweep onto opens instantly for timeout ms. Without it, a
toolbar makes you wait at every single icon.
Keyboard shortcuts
The most common piece of structure inside a real tooltip. Key renders
on-color caps, so they read on the plate with no extra styling.
Naming vs describing an icon button
relationship decides what the tip is to assistive tech, and it is the one
prop worth thinking about on every tooltip you write.
"description"(default) — the tip adds detail to a control that already has a name, viaaria-describedbywhile the tip is open."label"— the tip becomes the trigger's accessible name, for an icon-only button. The text is also rendered into an always-present visually hidden copy, because a name that only exists while the pointer hovers is no name at all; the visible popup is then hidden from AT so nothing is announced twice."none"— no ARIA wiring, for a trigger that is already fully described.
label is for a bare trigger you built yourself. The house
IconButton requires an aria-label or
aria-labelledby in its type — it can never be the unnamed case — so pair a
tooltip on one with description and let it add detail instead.
Disabled triggers
A disabled button fires no pointer events, so a tooltip attached to one never opens. Make the wrapper the trigger instead.
Prefer not to need this. In a survey of real products, nobody explained a disabled control with a hover tip — they used an inline banner or helper text, which a touch user and a screen-reader user can also reach.
API Reference
Tooltip
The root. Holds the style context and the open state.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Padding and text size |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner radius. The arrow's tip radius cascades from it |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Plate colour |
relationship | "description" | "label" | "none" | "description" | How the tip relates to the trigger for AT |
delay | number | 600 | ms before opening on hover |
closeDelay | number | 0 | ms before closing |
open | boolean | — | Controlled open state |
defaultOpen | boolean | — | Uncontrolled initial state |
onOpenChange | (open, event) => void | — | Fires on every open/close |
disabled | boolean | false | The tip never opens. Does not disable the trigger element |
hoverablePopup | boolean | true | Keep the tip open while the pointer is over the tip itself |
trackCursorAxis | "none" | "x" | "y" | "both" | "none" | Follow the cursor instead of anchoring to the trigger's box |
dir | "ltr" | "rtl" | "ltr" | Text direction applied to the popup |
TooltipTrigger
Renders a <button>, or any element via render. Accepts delay and
closeDelay to override the root's, plus every native button prop.
TooltipContent
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "top" | Which side of the trigger to sit on |
align | "start" | "center" | "end" | "center" | Alignment along that side |
sideOffset | number | 6 | Distance from the trigger, floored to 10 when the arrow is on |
alignOffset | number | 0 | Offset along the side axis |
collisionPadding | number | 8 | Keep this far from the viewport edge |
collisionAvoidance | object | — | Base UI's config. { side: "none" } pins the tip to its declared side |
arrow | boolean | true | Render the pointer |
portal | boolean | true | Render in a portal |
container | HTMLElement | null | document.body | Portal target |
positionerClassName | string | — | Classes for the positioner element |
size, rounded and intent can also be set here to override the root.
TooltipProvider
| Prop | Type | Default | Description |
|---|---|---|---|
delay | number | 600 | Shared open delay for the group |
closeDelay | number | 0 | Shared close delay |
timeout | number | 400 | Window after a tip closes during which the next opens instantly |
TooltipArrow
Rendered for you by TooltipContent. Pass one explicitly to add a className;
yours wins over the automatic one.
Accessibility
- The popup is
role="tooltip"with an id, and the trigger pointsaria-describedbyat it only while it is open — a permanent reference to an unmounted popup is a dangling IDREF that axe flags. - A caller's own
aria-describedby/aria-labelledbyis kept, and ours appended, never clobbered. - Escape closes the tip.
- The tip opens on keyboard focus, not just hover.
- Assistive tech: verified with VoiceOver on Safari and Chrome. NVDA and JAWS are untested — no Windows machine in the loop.