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.
PropTypeDefaultWhat it does
contentReact.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.
openboolean—Controlled open state. Leave it out and the tooltip owns it.
defaultOpenbooleanfalseWhether it starts open, when the tooltip owns its own state.
onOpenChangeChangeHandler<boolean, TooltipReason>—Fires with the new state and why it changed — 'hover', 'focus', 'pointer-leave', 'blur' or 'escape'.
openDelaynumber300How 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.
closeDelaynumber150The grace period after the pointer leaves, in ms. Default 150, so the pointer can reach the tooltip (WCAG 1.4.13).
sideAnchorSide'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.
alignAnchorAlign'center'Which of the trigger's edges to line the bubble up with along the other axis. Default 'center'.
offsetnumber1The 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.
flipbooleantrueWhether a side with no room may be swapped for its opposite. Default true.

Tooltip keyboard

KeyResult
TabShows it as soon as the trigger has focus, ignoring openDelay: a keyboard user asked for it by arriving.
EscapeHides it, leaving focus on the trigger. It stays hidden until the pointer or focus leaves and comes back.
Tab, againReaches the next control, and the tooltip closes behind it.

Pointer

KeyResult
Resting on the triggerShows it after openDelay, 300 ms by default: sweeping across a toolbar should not light every control up.
Leaving the triggerHides it after closeDelay, 150 ms — unless the pointer lands on the tooltip itself.
Moving onto the tooltipKeeps it open for as long as the pointer is there, so a long description can be read or selected.

Tooltip accessibility

  • role="tooltip" on the bubble and aria-describedby on 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 content is 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