Tooltip
A description that appears on hover and on focus, wired to its trigger with aria-describedby — the APG tooltip, including the three WCAG 1.4.13 rules everyone forgets.
Import
JSX
import Tooltip from '@box-kite/react/components/tooltip';Usage
JSX
<Tooltip content="Deletes the row for good">
{(trigger) => <Button {...trigger}>Delete</Button>}
</Tooltip>Why the trigger is a render prop
The tooltip has to put
aria-describedby on the element the user lands on — the control itself, not a wrapper around it. Cloning a child would mean guessing where that attribute goes, and this library has two answers: Box takes DOM attributes in a props bag, a plain <button> takes them at the top level. So the trigger is handed to you instead, and you say where it belongs. It carries a ref too — the bubble is positioned against the trigger's own box, which is what keeps it under the control and out of the layout.JSX
// A Box component — ref and props are both top-level Box props, so one spread does it.
<Tooltip content="Save the draft">{(trigger) => <Button {...trigger}>Save</Button>}</Tooltip>
// A plain element — the same two pieces, named.
<Tooltip content="Save the draft">{(trigger) => <button ref={trigger.ref} {...trigger.props}>Save</button>}</Tooltip>What the component guarantees
Dismissible — WCAG 1.4.13
Escape closes the tooltip and focus does not move. It stays closed while the pointer sits where it is: re-showing it because nothing moved would put the user straight back where they were.
Hoverable — WCAG 1.4.13
Leaving the trigger starts a
closeDelay (150 ms) rather than closing, so the pointer can travel onto the tooltip — to read a long description, or to select text in it. Once it is on the tooltip, the tooltip keeps it open.Persistent — WCAG 1.4.13
Nothing hides it on a timer. It closes when the pointer leaves and focus has moved on, and not before.
Controlled, with the reason it changed
last reason:
—JSX
const [open, setOpen] = useState(false);
<Tooltip
content="Deletes the row for good"
open={open}
onOpenChange={(next, { reason }) => {
setOpen(next);
console.log(reason); // 'hover' | 'focus' | 'pointer-leave' | 'blur' | 'escape'
}}
>
{(trigger) => <Button {...trigger}>Delete</Button>}
</Tooltip>Styling
Every Box prop on
<Tooltip> styles the bubble, on top of the built-in tooltip component style (inverted against the page, so it reads as an overlay in either theme). Where it lands is side, align and offset — the placement vocabulary Overlay and useAnchorPosition share — and a side with no room flips to its opposite on its own.JSX
<Tooltip content="On brand" bgColor="indigo-600" color="white" borderRadius={2} side="end" offset={2}>
{(trigger) => <Button {...trigger}>Delete</Button>}
</Tooltip>Only need the positioning?
A tooltip is a pattern; escaping an
overflow: hidden is not. The layer-and-position half of this component ships on its own as Overlay — no ARIA, no open state, no dismissal — and that is what to reach for when the thing being rendered is not a description of a control. Before A3 this component was that primitive, so code that used it to escape an overflow becomes Overlay, unchanged.JSX
import Overlay from '@box-kite/react/components/overlay';
<Overlay p={3} bgColor="slate-800">anything, anywhere</Overlay>Tooltip props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
content | React.ReactNode | — | The description itself. Nothing renders while this is empty. |
childrenrequired | (trigger: TooltipTrigger) => React.ReactNode | — | The trigger, handed the ref and props that wire it to the tooltip. |
open | boolean | — | Controlled open state. Leave it out and the tooltip owns it. |
defaultOpen | boolean | false | Whether it starts open, when the tooltip owns its own state. |
onOpenChange | ChangeHandler<boolean, TooltipReason> | — | Fires with the new state and why it changed — 'hover', 'focus', 'pointer-leave', 'blur' or 'escape'. |
openDelay | number | 300 | How long the pointer has to rest on the trigger, in ms. Default 300 — sweeping across a toolbar should not light every control up. Focus ignores it: a keyboard user asked for the tooltip by arriving. |
closeDelay | number | 150 | The grace period after the pointer leaves, in ms. Default 150, so the pointer can reach the tooltip (WCAG 1.4.13). |
side | AnchorSide | 'bottom' | Which side of the trigger the bubble sits on — top/bottom are the block axis, start/end the inline one. Default 'bottom', and a side with no room flips to its opposite either way. |
align | AnchorAlign | 'center' | Which of the trigger's edges to line the bubble up with along the other axis. Default 'center'. |
offset | number | 1 | The gap between trigger and bubble, on the ÷4 spacing scale. Default 1 — 4px, which is near enough for the pointer to cross without leaving the trigger (WCAG 1.4.13) and far enough to read as a layer. |
flip | boolean | true | Whether a side with no room may be swapped for its opposite. Default true. |
Tooltip keyboard
| Key | Result |
|---|---|
Tab | Shows it as soon as the trigger has focus, ignoring openDelay: a keyboard user asked for it by arriving. |
Escape | Hides it, leaving focus on the trigger. It stays hidden until the pointer or focus leaves and comes back. |
Tab, again | Reaches the next control, and the tooltip closes behind it. |
Pointer
| Key | Result |
|---|---|
Resting on the trigger | Shows it after openDelay, 300 ms by default: sweeping across a toolbar should not light every control up. |
Leaving the trigger | Hides it after closeDelay, 150 ms — unless the pointer lands on the tooltip itself. |
Moving onto the tooltip | Keeps it open for as long as the pointer is there, so a long description can be read or selected. |
Tooltip accessibility
Implements the WAI-ARIA APG pattern.
role="tooltip"on the bubble andaria-describedbyon the trigger, so the description is read as part of the control rather than as text that happens to be nearby.- WCAG 1.4.13, all three parts: Escape dismisses it without moving focus, the pointer can travel onto the bubble and read it, and nothing hides it on a timer.
- The bubble is never in the tab order — a focusable tooltip is a dialog with the wrong role — and it renders nothing at all while
contentis empty.
Swept with axe on every release, in 2 states:
Tooltip, Tooltip (open). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.Tooltip style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.tooltipvariants: closed