Tooltip
A tooltip is the trigger's accessible description: a few words that say
what an icon button does or expand an abbreviation. Interactive content
belongs in a Popover. Positioned
with @floating-ui/react (flip, shift, autoUpdate) and portalled to
<body>.
import Tooltip from '@zuilib/components/tooltip'
Basic
The child is cloned with the tooltip's ref, hover / focus handlers and
aria-describedby merged in. Its own ref, handlers and
aria-describedby are composed, not replaced. The trigger must be
keyboard focusable: a Button or a link. A plain <span>, <div> or
<svg> child gets tabIndex={0} automatically.
Placement and arrow
placement takes any Floating UI Placement; the tooltip flips to the
other side when there is no room. arrow draws a small triangle that
fills with the same --tooltip-surface colour as the bubble, and the
arrow height is added to the offset automatically.
Delay and disabled
delay applies to hover only; keyboard focus opens at once. A single
number sets both open and close, an object sets each. disabled never
opens and closes an open tooltip, while the trigger keeps rendering.
Controlled and render-prop trigger
open / onOpenChange control the state. Pass a function as children
to spread the trigger props yourself; spread all of them onto one
focusable element.
Recolouring
The bubble is --foreground on --background inverted so it reads over
any surface. The colour goes through --tooltip-surface, which the arrow
follows, so one class recolours both.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
contentrequired | ReactNode | — | What the tooltip says; keep it short, it is the trigger's accessible description |
childrenrequired | ReactElement | (props: TooltipTriggerProps) => ReactNode | — | The trigger. An element is cloned with the trigger props; a function receives them to spread onto one focusable element |
placement | Placement | 'top' | Floating UI placement (top, bottom-start, …); flips when there is no room |
delay | number | { open?: number; close?: number } | { open: 200, close: 0 } | Hover delay in ms; keyboard focus opens at once |
offset | number | 1.5 × --spacing (6px) | Gap between trigger and panel in px; the arrow height is added automatically |
arrow | boolean | false | Draw a small arrow pointing at the trigger |
disabled | boolean | false | Never opens; closes an open tooltip. The trigger still renders |
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Initial state when uncontrolled |
onOpenChange | (open: boolean) => void | — | Called on every open / close |
portal | boolean | true | Render under <body>. false keeps the panel inline so subtree token overrides and a subtree .dark reach it |
className | string | — | Merged last onto the panel. Set --tooltip-surface to recolour the bubble and arrow together |
arrowClassName | string | — | Merged last onto the arrow svg |
panelProps | Omit<HTMLAttributes<HTMLDivElement>, 'className' | 'children' | 'role' | 'id'> | — | Extra panel attributes (style, data-*). The id is owned by the tooltip |
ref | Ref<HTMLDivElement> | — | The panel element |
TooltipTriggerProps (the render-prop argument) is the callback ref,
the hover / focus / dismiss handlers, aria-describedby while open, and
data-state.
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="tooltip"] | div | The panel. Also data-state="open", data-placement (resolved placement) and data-side (top of top-start) |
[data-slot="tooltip-arrow"] | svg | Present with arrow; fills with --tooltip-surface |
The trigger element gets data-state="open" | "closed" and no slot of
its own (it is the consumer's element).
/* Consumer CSS: rounder tooltips that slide from the side they sit on */
[data-slot="tooltip"] {
border-radius: var(--radius-lg);
}
[data-slot="tooltip"][data-side="bottom"] {
animation-name: slide-in-from-top;
}
Tokens
| Token | Used for |
|---|---|
--foreground, --background | Inverted bubble colours (via --tooltip-surface) |
--radius-sm | Corner radius |
--text-xs | Type size |
--shadow-medium | Panel shadow |
--spacing | Default offset (× 1.5) and viewport padding (× 2) |
--duration-* via animate-fade-in | Mount fade, off under prefers-reduced-motion |
Accessibility
- The panel has
role="tooltip"; while open the trigger'saria-describedbypoints at it, joined onto any description the trigger already had. - Opens on mouse or pen hover (after
delay.open) and on keyboard focus (at once). Touch taps do not open it, so it can never stick on a phone. - Moving the pointer from the trigger onto the tooltip keeps it open, so its text can be hovered and selected (WCAG 1.4.13).
- Closes on Escape, on pressing the trigger, and on an outside press. Escape is not swallowed: a surrounding Dialog closes on the same press.
- The trigger must be focusable or keyboard and screen-reader users can never open it. Host
<span>/<div>/<svg>children gettabIndex={0}; a custom component that mounts an unfocusable node logs a warning in development. - The content is a description: an icon button still needs its own
aria-labelfor its name.