Design a tooltip
A design-system tooltip: a short line of text that describes a button or icon when you point at it or reach it with the keyboard. Showing a box is easy; the real work is knowing what a tooltip must never hold, wiring the ARIA so screen readers hear it, meeting WCAG's hover rules (dismissible, hoverable, persistent), timing it with delays and a warm-up group, keeping it on screen inside scroll containers, rendering it above everything without z-index fights, and handling touch screens that have no hover at all.
The question
Design a tooltip component for our design system. When someone points at an icon button, like the trash can in a mail toolbar, or tabs to it with the keyboard, a short label such as "Delete" or "Copy link (Ctrl+L)" appears next to it.
Tell me how it behaves for mouse, keyboard, touch and screen reader users, how you time it, how you keep it on screen when the trigger sits in a scrolling panel near the edge, and what the API looks like for the teams that will use it in a data table with 2,000 rows.
Real-life examples: The icon buttons in the Gmail toolbar (Archive, Report spam, Delete), The formatting buttons in Google Docs, with their keyboard shortcuts, GitHub's icon buttons on a pull request, Truncated file names in the Google Drive list, The buttons in the VS Code activity bar
Requirements: what a tooltip is, and what it is not
A tooltip is a small box of text that appears next to an element, called its trigger, when you point at the trigger with a mouse or move keyboard focus onto it. It goes away when you move away. In a mail app, the trash can icon shows "Delete". In a text editor, the bold button shows "Bold (Ctrl+B)".
It looks like the smallest component in a design system: a div that appears on mouseenter. That's exactly why interviewers ask it. A naive tooltip fails someone: keyboard users never see it, screen readers never hear it, phone users can't open it, and a scrolling panel cuts it off. A strong answer names those groups early and designs for each.
The definition that drives every later decision
The most useful thing to say in the first two minutes is a crisp definition. A tooltip is a short, non-interactive description of its trigger. Each word rules something out:
- Short: one line, maybe two, like "Archive" or "Last edited 3 minutes ago". A paragraph belongs on the page or in a help panel.
- Non-interactive: no links, buttons or inputs inside. A tooltip closes when focus leaves the trigger, so a keyboard user could never reach a link in it. Anything clickable belongs in a popover (a floating panel you can tab into) or a toggletip (a small info button that opens a bubble on click).
- Description of its trigger: it explains the thing it's attached to. It's not a place for promotions, onboarding tips, or errors, which belong next to the field, always visible.
- Supplementary: the page must work without it. Touch screens have no hover, and many users never wait for it. Essential information, like a price condition or a required format, can never live only in a tooltip.
Questions to ask
The middle column is what this guide assumes. The right column is why the answer matters.
| Question | Answer we assume | What it changes |
|---|---|---|
| What goes inside? | Plain text, one or two lines, at most about 240 px wide. Sometimes a keyboard shortcut hint. | Rules out interactive content and focus management. Keeps the bubble simple and the API small. |
| Is the tooltip ever the only name of the trigger? | Yes: icon-only toolbar buttons, where "Delete" is the button's name. Elsewhere it adds detail to a button that already has a visible label. | Two ARIA wirings: label (aria-label or aria-labelledby) and description (aria-describedby). |
| Which devices? | Desktop with mouse and keyboard, plus phones and tablets. About 40% of traffic is touch. | Touch has no hover. A tap must do the button's job, so touch needs its own plan. |
| Where do triggers live? | Toolbars, cards, side panels that scroll, modal dialogs, and a data table with up to 2,000 rows. | Positioning inside scroll containers, rendering above dialogs, and the cost of thousands of triggers. |
| Server rendered? | Yes, the app uses server rendering with hydration. | Ids that link trigger and tooltip must match on server and client. Tooltip markup should not be in the HTML. |
| Accessibility target? | WCAG 2.2 AA, following the WAI-ARIA Authoring Practices (APG) where it gives guidance. | Success criterion 1.4.13 (Content on Hover or Focus) is the core rule for tooltips. |
| Animation? | A short fade, about 100 ms, with no motion when the user asks for reduced motion. | prefers-reduced-motion, and timing that doesn't fight the show delay. |
Functional requirements
- Show on hover after a short delay, and on keyboard focus.
- Hide when the pointer leaves trigger and tooltip, when focus leaves, and on Escape.
- Let the pointer move onto the tooltip without it closing, so people using screen magnifiers can read it.
- Place it on a preferred side (top by default), flip when there's no room, and slide along the edge to stay on screen.
- Work as the name of an icon-only button or as extra description for a labelled one.
- Work on disabled buttons, which can't take focus and, in some browsers, don't fire mouse events.
- Offer a "show only when truncated" mode for text cut off with an ellipsis.
Non-functional requirements
- Accessible: WCAG 2.2 AA, including 1.4.13, for keyboard, screen reader, magnifier and touch users.
- Fast: opening stays well under a 16 ms frame on a mid-range laptop, and a table with 6,000 triggers must not add 6,000 sets of listeners, timers or positioning engines.
- Small: under about 5 KB gzipped, plus positioning code that many apps already ship for menus.
- Always visible: never clipped by
overflow: hiddenor stuck under a sticky header or modal. - Predictable: the same delays everywhere, so the whole product feels consistent.
Out of scope, and why
- Rich popovers with links, buttons, images or forms: a different component with focus management. We only design the handoff.
- Onboarding tours ("New! Try filters"): dialogs with their own dismissal rules.
- Form validation messages: they must stay visible and be announced as errors.
- Native
titleattribute styling: we won't usetitleat all, for reasons in the next chapter.
Check your understanding · Exercise 1
A product manager asks for these four things in the tooltip component. Which ones should you push back on and move to a different component? Select all that apply.
What makes it hard, and why the title attribute isn't enough
Five things make a tooltip harder than it looks. Every later chapter answers one of them.
- Four kinds of input. Mouse, keyboard, finger and screen reader each need the same text by a different path.
- Time. Show too fast and bubbles flash as the mouse crosses a toolbar. Show too slow and people give up. Hide too fast and the bubble vanishes while you move onto it.
- Space. The trigger can sit at a screen edge, in a scrolling panel, or in a cell with
overflow: hidden, and the bubble must still find room. - Layers. The bubble must appear above cards, sticky headers, menus and modal dialogs, and
z-index: 9999doesn't always win. - Scale. A table with 2,000 rows and 3 icon buttons per row has 6,000 triggers. If each owns listeners and timers, the page slows down before anyone hovers.
The naive versions, and how they break
Most teams start with one of three shortcuts, and the interviewer wants to hear why each one fails.
Decision How should we show the tooltip text at all?
What we pick: Build the JavaScript component, and don't also put a title on the trigger (you'd get two bubbles and a doubled announcement). The CSS-only version is fine for a prototype until the first scrolling table cell clips it.
Think first
A teammate says: "The title attribute is accessible, it's in the HTML spec." What's your answer?
The HTML spec itself discourages relying on title, because browsers don't show it on keyboard focus or touch. It does feed the accessibility tree, but our component gives the same text to every user, timed and dismissible.
The decisions ahead
Here's the order this guide takes, which also works in the interview: accessibility first, because it decides what the component is, then timing, positioning, rendering, scale and the API.
| Chapter | Decision | Our pick |
|---|---|---|
| 3 | How screen readers get the text, and how we meet WCAG 1.4.13 | aria-label for icon-only buttons, aria-describedby for extra detail; Escape, hoverable, persistent |
| 4 | When it opens and closes | Hover with a 500 ms delay, keyboard focus at once, a warm-up group, a 100 ms close delay and a safe area |
| 5 | Where it goes | A placement engine with offset, flip, shift and arrow, re-run on scroll and resize |
| 6 | Which layer it renders in | The top layer through the Popover API, with a portal as the fallback |
| 7 | One instance or many | One shared controller and one bubble, with event delegation |
| 8 | The API | A small Tooltip wrapper plus a provider, with props for placement, delay and truncation |
| 9 | Touch, motion, server rendering, tests | No tooltip on tap, a fade that respects reduced motion, ids from useId, fake timers |
Accessibility first: the ARIA wiring and WCAG's hover rules
Accessibility comes first because it decides what the component is. A screen reader user hears the text on focus, a keyboard user needs a way to close the bubble, and a magnifier user at 400% has to move the mouse onto the bubble to read it. The ARIA patterns lesson covers roles and states in general; here we apply them.
Two jobs: a name or a description
Every interactive element has an accessible name (what it is, like "Delete") and may have an accessible description (extra detail, like "Moves the conversation to Trash"). A screen reader reads them in that order: "Delete, button, Moves the conversation to Trash". A tooltip does one of these jobs, and the component must know which:
- Label mode: the trigger is an icon-only button with no visible text. The tooltip text is its name. Without it, a screen reader says just "button".
- Description mode: the trigger already has a name (visible text or an
aria-label), and the tooltip adds detail, like a keyboard shortcut.
Mixing them up causes real bugs. If an icon button has aria-label="Delete" and the tooltip "Delete" is also its description, the screen reader says "Delete, button, Delete". If the tooltip is the only text and it's wired as a description, the button has no name at all, and checkers like axe flag it.
What the APG tooltip pattern says
The WAI-ARIA Authoring Practices Guide (APG) describes a tooltip pattern: the bubble has role="tooltip", the trigger points to it with aria-describedby, focus stays on the trigger, and Escape closes the tooltip, which never receives focus itself. The APG marks this pattern as work in progress without task force consensus, and it has no official example yet. So treat it as a baseline, not a complete spec, and test with real screen readers (NVDA and JAWS on Windows, VoiceOver on macOS and iOS, TalkBack on Android).
One practical detail matters most: role="tooltip" on its own does very little, because screen readers mostly don't announce the role. What makes the text heard is the relationship: aria-describedby (or aria-labelledby) on the trigger, pointing at an element id. And that relationship has a timing problem.
Decision What should the trigger's aria-describedby point at?
What we pick: Label mode: put the text in aria-label on the trigger, present in the server HTML. Description mode: use the always-present hidden span and aria-describedby. The visible bubble is then a visual layer with aria-hidden="true", so nothing is read twice. If your team wants the APG markup exactly, keep role="tooltip" on the bubble, but mount it before or at focus, never after a delay.
<!-- Label mode: icon-only button, tooltip text is the name -->
<button type="button" aria-label="Delete">
<svg aria-hidden="true">...</svg>
</button>
<!-- Description mode: visible name, tooltip adds detail -->
<button type="button" aria-describedby="tip-r3">Bold</button>
<span id="tip-r3" hidden>Ctrl+B</span>
<!-- The visible bubble, a visual copy only -->
<div class="tooltip" aria-hidden="true">Ctrl+B</div>
Check your understanding · Exercise 2
A toolbar has an icon-only trash button. Which setups give a screen reader user "Delete, button" with nothing repeated? Select all that apply.
WCAG 1.4.13: dismissible, hoverable, persistent
WCAG 2.1 added success criterion 1.4.13 Content on Hover or Focus at level AA, and WCAG 2.2 keeps it. It covers content that appears on hover or focus, which is exactly what a tooltip is. It has three conditions, quoted below, and each one turns into code:
| Condition | What WCAG says | How the component meets it |
|---|---|---|
| Dismissible | "A mechanism is available to dismiss the additional content without moving pointer hover or keyboard focus." | A keydown listener for Escape while a tooltip is open. It closes the bubble and keeps focus where it is. The bubble stays closed until the pointer leaves or focus moves. |
| Hoverable | "If pointer hover can trigger the additional content, then the pointer can be moved over the additional content without the additional content disappearing." | The bubble accepts pointer events (never pointer-events: none), and closing waits for a short grace delay and a safe area between trigger and bubble (chapter 4). |
| Persistent | "The additional content remains visible until the hover or focus trigger is removed, the user dismisses it, or its information is no longer valid." | No auto-hide timer. The tooltip only closes on leave, blur, Escape, or when the trigger disappears. |
The criterion has exceptions, and none of them helps a toolbar tooltip. Dismissible doesn't apply when the content communicates an input error or doesn't obscure or replace other content. And the whole criterion skips content whose presentation the browser controls and the author doesn't modify, like the native title box.
The Escape rule has one sharp edge: inside a modal dialog, Escape also closes the dialog, and hiding a tooltip shouldn't throw away a half-filled form. So while a tooltip is open, its capture-phase keydown handler closes it and calls preventDefault() and stopPropagation(); the next Escape closes the dialog. Test this with your dialog component, because browsers have changed how a native dialog handles Escape over time.
aria-describedby the moment focus lands. Escape hides the bubble, stops the event so a dialog around it stays open, and leaves focus where it was.When it opens and closes: delays, warm-up groups and the safe area
The naive tooltip opens on mouseenter and closes on mouseleave. Picture a toolbar of 10 icon buttons, each 40 px wide, and a user sweeping the mouse across it at 1,000 px per second on the way to the message list. The pointer spends 40 ms over each button, so 10 bubbles nobody wanted flash on and off in 0.4 seconds.
Then the user clicks Archive. The click focuses the button, a focus handler opens the tooltip, and the bubble covers the "Conversation archived" toast. And a bubble the user tries to move onto closes in the 8 px gap: three bugs, all about time.
Which events open it
- Pointer hover: listen to
pointerenterandpointerleave, ignoringpointerTypetouch. Touch screens also fire compatibility mouse events after a tap, which is whymouseentertooltips stick open on phones. - Keyboard focus: listen to
focusandblur, but open only iftrigger.matches(":focus-visible"). Browsers match:focus-visiblewhen focus came from the keyboard, and usually not when a mouse click focused a button, so this one check removes the "tooltip after click" bug. It also behaves well when a dialog returns focus: no tooltip after a mouse close, a tooltip after a keyboard close. - Press: on
pointerdown, close the tooltip and mark it dismissed, so the bubble doesn't cover the result. - Escape: close and mark dismissed, as WCAG 1.4.13 requires.
"Dismissed" means: stay closed until the pointer leaves the trigger or focus moves away. Without it, the bubble reopens when the mouse moves 1 px after Escape.
The timers
| Timer | Our value | Why |
|---|---|---|
| Open delay (hover) | 500 ms | Long enough that passing over a button doesn't open it, short enough that someone who stops to wonder gets an answer. Library defaults vary widely (MUI 100 ms, Radix 700 ms, React Aria 1,500 ms), so pick one value and use it everywhere. |
| Open delay (keyboard focus) | 0 ms | Moving focus is a deliberate act, one stop at a time. A delay only slows keyboard users down. |
| Close delay (grace) | 100 ms | Gives the pointer time to cross the gap onto the bubble, and stops flicker when the pointer grazes the trigger's edge. |
| Warm-up window (cool-down) | 300 ms | After a tooltip closes, a new one in the same group opens with no delay if the pointer arrives within this window. Radix uses 300 ms (skipDelayDuration), React Aria 500 ms. |
The warm-up group
Desktop operating systems settled this long ago: the first tooltip waits, but once one is showing, the neighbouring button's tooltip shows at once, because the user is clearly reading tooltips. We call this a delay group: all tooltips under one provider share one piece of state, "is the group warm, and until when?" Radix's provider and Floating UI's FloatingDelayGroup ship the same idea.
- Cold group: the pointer enters Archive, waits 500 ms, and "Archive" shows. The group is now warm.
- The pointer moves to Spam. Archive closes at once and Spam opens with no delay or animation, like one bubble that moved.
- The pointer leaves the toolbar. The last tooltip closes, and the group stays warm for 300 ms.
- Back within 300 ms, the next tooltip opens instantly. After that the group is cold and the 500 ms delay returns.
Try the timers in the lab below. Each pointer path is a real situation: a quick sweep across a toolbar on the way elsewhere, reading two tooltips and coming back a moment later, and a slow move onto the bubble. Change one setting at a time and watch which bubbles open, and when.
Getting the pointer onto the bubble
WCAG's hoverable condition says the pointer must be able to reach the bubble. The bubble sits 8 px from the trigger so its arrow has room, and when it has shifted sideways to stay on screen, the pointer travels diagonally over empty page to reach it.
Decision How do we keep the tooltip open while the pointer travels from the trigger to the bubble?
What we pick: Use the grace timer plus a safe area. The timer handles straight moves and a pointer that stops; the triangle handles the diagonal move toward a shifted bubble. Its listener only exists between leaving the trigger and reaching the bubble or closing.
Putting it together: a state machine
Six flags (hovered, focused, open, dismissed, openTimer, closeTimer) give 64 combinations, most of them bugs. A state machine makes the allowed states explicit: each event moves the tooltip from one named state to another, and timers belong to states.
type State = "closed" | "opening" | "open" | "closing" | "dismissed";
type Event =
| { type: "hover" | "focus"; warm: boolean }
| { type: "leave" | "blur" | "openTimer" | "closeTimer" }
| { type: "enterBubble" | "escape" | "press" };
function next(state: State, e: Event): State {
switch (e.type) {
case "hover":
if (state === "dismissed") return state;
if (state === "closing") return "open";
return state === "open" || e.warm ? "open" : "opening";
case "focus":
return state === "dismissed" ? state : "open";
case "openTimer":
return state === "opening" ? "open" : state;
case "leave":
case "blur":
if (state === "opening" || state === "dismissed") return "closed";
return state === "open" ? "closing" : state;
case "enterBubble":
return state === "closing" ? "open" : state;
case "closeTimer":
return state === "closing" ? "closed" : state;
default: // escape or press
return "dismissed";
}
}
Real code tracks hover and focus separately: if the pointer leaves while the trigger still has keyboard focus, the tooltip stays open. That's one more check in the leave and blur cases, left out above to keep it short.
Here's one hover from start to paint, including a step from chapter 7: the positioning code downloads on the first open, while the 500 ms timer runs, and may fail.
import() starts with the timer, so the chunk is usually there before the bubble needs it. If it fails, the tooltip still shows, placed below the trigger and clamped to the screen, with no arrow.Check your understanding · Exercise 3
Which of these timing behaviors are correct for our tooltip? Select all that apply.
Where it goes: placement, flip, shift and the arrow
The naive position is one line, top: rect.top - height - 8, centered. It works mid-screen and fails at every edge, in every scrolling panel, and under any transform ancestor. The dropdown select guide covers the same placement rules; a tooltip adds an arrow and a hide step.
The vocabulary
- Placement: a side and an alignment, like
toporbottom-start.topis our default, because it doesn't cover the content people read next. - Offset: the gap between trigger and bubble, 8 px here, so the arrow fits.
- Flip: if the preferred side has no room, try the opposite side.
- Shift: slide the bubble along its side to stay inside the boundary, keeping 8 px of padding.
- Arrow: a small triangle pointing at the trigger's center. After a shift it moves to keep pointing at the trigger, clamped away from the rounded corners.
- Boundary: the area the bubble must fit in, usually the viewport, though clipping ancestors matter too.
A worked example in the top-right corner
The viewport is 1,280 by 720 px. The "Settings" icon button sits in the top-right corner: x from 1,240 to 1,272, y from 8 to 40. The bubble measures 180 by 32 px. Preferred placement: top, offset 8, padding 8.
- Place: centered above means x = 1,256 - 90 = 1,166 and y = 8 - 8 - 32 = -32. The top 32 px are off screen.
- Flip: below has 680 px of room, so flip to
bottom: y = 40 + 8 = 48. - Shift: the right edge is at 1,166 + 180 = 1,346, but the limit is 1,280 - 8 = 1,272. Shift left by 74: x = 1,092.
- Arrow: the trigger's center is 1,256 - 1,092 = 164 px from the bubble's left edge. The allowed range is 12 to 168 (keeping clear of the corners), so the arrow sits at 164, near the right end, pointing up at the gear.
Order matters. Flip has to run before shift, or shift slides a bubble that's about to move to the other side. The arrow runs after shift because it depends on the final x, and hide runs last. That's why positioning engines are built as a pipeline.
Scroll containers, clipping and transforms
Three page features break the naive math even in the middle of the screen:
- Clipping ancestors: a table cell, card or side panel with
overflow: hiddenoroverflow: autocuts off any child that sticks out, so a bubble rendered inside it gets sliced. - Scrolling: when the panel scrolls, the trigger moves but the bubble doesn't unless placement re-runs. And once the trigger scrolls out of view, the bubble shouldn't float on alone over the header.
- Containing blocks: an ancestor with
transform,filter,perspective,contain: paintorwill-change: transformbecomes the reference box forposition: fixedchildren, so a "fixed" bubble inside an animated card is positioned and clipped by the card.
Clipping and containing blocks are fixed by rendering the bubble outside the trigger's subtree (chapter 6). Scrolling is fixed by re-running placement while open, on scroll of every scrolling ancestor, resize of the window, and a ResizeObserver on trigger and bubble. Attach these on open and remove them on close, so closed triggers cost nothing, and add a hide step for when the trigger leaves its clipping ancestor.
The lab below puts a trigger inside a side panel that scrolls and clips, like a comments panel. Render the bubble inline and move the trigger toward the panel's edge to see clipping, then switch to the top layer. Scroll until the trigger leaves the panel, with and without the hide step.
Choosing the engine
Decision What computes the position?
What we pick: Use the JavaScript engine today, loaded on first open, because it handles shift and the arrow exactly in every browser. The dropdown select guide prefers anchor positioning where supported, and a tooltip without an arrow can too. Revisit when analytics show the older browsers have gone.
Check your understanding · Exercise 4
A tooltip on a button inside a side panel with overflow: auto gets cut off, and when the panel scrolls, the bubble stays behind. Which fixes address the cause? Select all that apply.
Which layer it renders in: portals, the top layer and popover="hint"
Positioning decides the coordinates; rendering decides which box they're inside and what paints on top. Get it wrong and a perfectly placed bubble is sliced by a table cell or hidden under a sticky header.
Why z-index: 9999 loses
z-index only compares elements inside the same stacking context, and any ancestor with position plus a z-index, an opacity below 1, a transform, or isolation: isolate starts a new one. So a bubble with z-index: 9999 inside a card with transform: translateZ(0) still paints below a sibling card's dropdown, because the whole card stacks as one unit. Raising numbers further starts a z-index war that nobody wins; the modal dialog guide tells the same story.
One layer is out of reach for z-index entirely: the top layer. A dialog opened with showModal(), a fullscreen element, and a shown popover render there, above the whole document, in the order they were added.
z-index only competes within a stacking context, so the card traps its 9999 bubble. The top layer sits above the whole document and stacks in the order things were shown, so a tooltip shown after a dialog paints above it.Decision Where should the bubble's element live?
What we pick: Use the top layer through the Popover API, and mount the bubble inside the nearest open dialog when there is one (otherwise at the end of body). That ends clipping, stacking and inertness problems together, as in the modal dialog and toast guides. Keep the portal only as a fallback for browsers older than 2024.
Which popover type: auto, manual or hint
The popover attribute has three values, and the choice matters for tooltips:
| Value | Behavior | For a tooltip? |
|---|---|---|
auto | Light dismiss (click outside or Escape closes it). Opening one closes other open auto popovers that aren't its ancestors. | No. Hovering a button inside an open menu would close the menu. |
manual | No light dismiss, closes nothing else. You open and close it yourself. | Yes, and it works everywhere the Popover API does. We handle Escape ourselves. |
hint | Made for tooltips. Light dismiss like auto, and opening one closes other hints, but it doesn't close open auto popovers like menus. | The best fit where supported. Chromium shipped it first, in version 133 (early 2025), and refined it later; Firefox followed in 2026, and Safari has it only in preview at the time of writing. Feature-detect it and fall back to manual. |
let popoverType: "hint" | "manual" | undefined;
function getPopoverType() {
if (!popoverType) {
const probe = document.createElement("div");
probe.popover = "hint";
popoverType = probe.popover === "hint" ? "hint" : "manual";
}
return popoverType;
}
export function showBubble(bubble: HTMLElement, text: string) {
bubble.textContent = text;
bubble.popover = getPopoverType();
if (!bubble.matches(":popover-open")) bubble.showPopover();
}
What's coming: interest invokers
Interest invokers add an interestfor attribute: button interestfor="tip-3" shows that popover when the user shows "interest" by hovering, focusing, or long-pressing on touch, with delays set in CSS. The browser then owns the timing, the hover bridge and much of the accessibility wiring. Chromium shipped it in version 142 (late 2025); Firefox and Safari haven't yet, and details may change. Keep our API shaped so a later version can switch to it without teams changing their code.
Check your understanding · Exercise 5
A tooltip on a button inside a modal dialog appears behind the dialog's dimmed backdrop. The bubble is portaled to the end of body with z-index: 10000. What's true? Select all that apply.
Architecture: one shared tooltip, not 6,000
So far we've designed one tooltip. Now put it in the data table from the requirements: 2,000 rows with 3 icon buttons each (Edit, Duplicate, Delete), so 6,000 triggers. If each Tooltip is self-contained, each owns a state hook, 4 to 6 event listeners, two timers, a hidden popover element, and maybe a positioning subscription.
Rough cost: 6,000 components to render and hydrate, about 30,000 listeners, and 6,000 extra elements, all for a feature that shows one bubble at a time. On a mid-range phone that's easily tens of milliseconds of extra hydration work, which can delay the first interaction and hurt Interaction to Next Paint. The runtime performance lesson covers measuring it; here we design it away.
Decision How many tooltip instances should exist?
What we pick: One shared controller and one bubble. Triggers stay cheap (a data attribute, an aria-label or hidden span, and a registry entry), and the cost no longer grows with the table.
The parts and their jobs
| Part | Responsibility | Why this boundary |
|---|---|---|
TooltipProvider | Creates the controller, holds group settings (delays, warm-up window), and renders the bubble layer. | One per app (and one inside each modal dialog). Settings stay consistent product-wide. |
Tooltip wrapper | Adds data-tooltip, aria-label or aria-describedby to its child, renders the hidden text span, and registers its content. | The only part product teams touch. It does no work at runtime, so it's cheap in a 6,000-row page. |
| Registry | A Map from tooltip id to content and options. | Lets the controller find content without React re-renders of the triggers. |
| Controller | Delegated listeners, the state machine from chapter 4, timers, the warm-up group, Escape handling. | Plain TypeScript, no React, so it's easy to unit test with fake timers. |
| Positioner | Measures the trigger and bubble and runs offset, flip, shift, arrow and hide; keeps it attached while open. | Loaded on first open with a dynamic import(), so pages where nobody hovers never pay for it. |
| Bubble layer | The one popover element: sets its text, shows and hides it, applies the position and the arrow. | Owns the top-layer details from chapter 6, including mounting inside an open dialog. |
The lazy positioner needs a failure plan. If its chunk fails to download (a flaky connection, a deploy that removed old files), the tooltip still opens: the bubble layer places it below the trigger, clamps it to the viewport, and leaves out the arrow. It retries the import on the next open, because a slightly worse position beats no text.
How data flows
A tooltip has no API of its own, but it still has data. The text usually comes from the translation bundle ("toolbar.delete": "Delete") and ends up in two places: the trigger's accessible markup in the server HTML, and the registry. At runtime, events flow the other way: input reaches the controller, which reads the registry, the positioner reads the layout, and the bubble paints.
The data model
type Placement = "top" | "bottom" | "left" | "right"
| `${"top" | "bottom" | "left" | "right"}-${"start" | "end"}`;
type TooltipEntry = {
id: string; // from useId(), stable across server and client
content: string; // plain text only
mode: "label" | "description";
placement: Placement; // preferred side; flip may change it
openDelay?: number; // overrides the group's 500 ms
onlyWhenTruncated?: boolean;
};
type ControllerState = {
status: "closed" | "opening" | "open" | "closing" | "dismissed";
activeId: string | null; // the one tooltip that may be open
trigger: HTMLElement | null;
openedBy: "pointer" | "focus" | null;
warmUntil: number; // performance.now() deadline for the group
};
type Registry = Map<string, TooltipEntry>;
| Data | Where it lives | Why |
|---|---|---|
| Tooltip text | Props, from the translation bundle; copied into the registry and the hidden span | The server HTML must contain it for screen readers and for no-JavaScript cases. |
| Which tooltip is open, and its status | The controller, outside React | Changes many times per second during hover; keeping it out of React avoids re-rendering triggers. |
| Warm-up deadline | The controller | Shared by the whole group; only the controller needs it. |
| Final placement and coordinates | The bubble element's style, plus data-placement and data-open attributes | Read by CSS for the arrow's side and the fade, and by tests; never needed in React state. |
| Delay settings | Provider props, with per-tooltip overrides | One product-wide default, rare exceptions. |
Check your understanding · Exercise 6
Why does the shared controller listen for pointerover and focusin instead of pointerenter and focus? Select all that apply.
The component API: small, hard to misuse
Product teams will use this component thousands of times, so the API should make the right thing short and the wrong thing hard. The component API design lesson covers the general rules; here are the tooltip-specific ones.
Decision What shape should the API take?
What we pick: The wrapper for everyday use, plus the hook as an escape hatch for unusual triggers. Avoid compound components here: their flexibility is exactly what we want to forbid, so they belong in popovers.
type TooltipProps = {
content: string; // string, not ReactNode: no links inside
mode?: "label" | "description"; // default "description"
placement?: Placement; // default "top"
openDelay?: number; // default from the provider, 500 ms
onlyWhenTruncated?: boolean; // for text cut off with an ellipsis
disabled?: boolean; // turn the tooltip off, not the trigger
children: React.ReactElement; // exactly one trigger
};
<TooltipProvider openDelay={500} closeDelay={100} warmWindow={300}>
<Tooltip content="Delete" mode="label">
<IconButton icon={TrashIcon} onClick={remove} />
</Tooltip>
<Tooltip content="Ctrl+B">
<Button onClick={toggleBold}>Bold</Button>
</Tooltip>
<Tooltip content={file.name} onlyWhenTruncated>
<span className="truncate">{file.name}</span>
</Tooltip>
</TooltipProvider>
contentis a string. The type system enforces the definition from chapter 1: a team that needs a link gets a type error and finds the toggletip.modeis explicit. In development builds, ifmodeis "description" and the mounted trigger has no accessible name, the component logs a warning pointing atmode="label".- No
openprop by default. A controlledopeninvites onboarding tours built on tooltips. Tests use real events with fake timers instead (chapter 9).
Disabled triggers
A common request: "Show why the Publish button is disabled." A native disabled button breaks this twice. It can't receive focus, so keyboard users never see the tooltip, and browsers have historically not fired mouse events on disabled form controls, with differences between engines, so hover is unreliable too.
Decision How do we explain a disabled action?
What we pick: Prefer aria-disabled in the design system's Button whenever the reason matters, and wire the reason as the description. If a team must keep a native disabled button, the wrapper is the fallback for mouse users, and the reason should also be visible text near the button. The keyboard navigation lesson explains why focusable but unavailable controls are often friendlier.
Tooltips for truncated text
File names and email subjects are often cut off with text-overflow: ellipsis. A tooltip with the full text helps, but only when the text is actually cut off; repeating "Budget.xlsx" over "Budget.xlsx" is noise.
- Check
el.scrollWidth > el.clientWidthat open time, not at render time. Widths change when the window resizes or a panel opens, and checking on open means one measurement per hover, not 2,000 on page load. - Screen readers already read the full text, since truncation is only visual, so this mode adds no
aria-describedbyand no hidden span. - Keyboard users need to focus the text to see it. In a data table, cells are already focusable through the grid's roving focus, so the tooltip opens on cell focus.
Check your understanding · Exercise 7
Which API decisions help prevent misuse of the tooltip? Select all that apply.
Touch, motion, server rendering and tests
Touch screens have no hover
On a phone no pointer rests over a button: a finger arrives, taps, and leaves. So what does a touch user get instead?
Decision What should a touch user get?
What we pick: Don't rely on tooltips on touch: a tap does the action, and anything users need is visible or in a toggletip, with long press as an optional extra for dense toolbars. Decide per event with event.pointerType, not per device with matchMedia("(hover: hover)"), because touch-screen laptops have both a mouse and a finger.
Animation and reduced motion
- Fade in over about 100 ms with a 4 px slide, using only
opacityandtransform, which skip layout. - No animation when switching inside a warm group, so it reads as one bubble following the pointer.
- Reduced motion: under
@media (prefers-reduced-motion: reduce), drop the slide and keep a short fade or nothing (the visual accessibility lesson explains why). - Entry and exit with popovers: animating to and from
display: noneneeds@starting-styleandtransition-behavior: allow-discrete; browsers without them just skip the animation. - Never let animation delay the content. The 500 ms open delay is already the wait; a 300 ms fade on top feels slow.
Server rendering and hydration
- Ids from
useId(): React generates the same id on the server and the client, soaria-describedbymatches the hidden span after hydration. Counters orMath.random()produce mismatches. - Accessible text in the server HTML:
aria-labeland hidden spans render on the server, so screen readers get names and descriptions before JavaScript loads. - No bubble on the server: the provider creates the bubble and attaches listeners in an effect, after hydration. Until then hovering does nothing, which is acceptable for supplementary content.
- Measure only on the client, on open, so render has nothing to mismatch.
Performance
Opening a tooltip costs a registry lookup, two getBoundingClientRect() calls and some arithmetic. While open, batch scroll updates to one per animation frame with requestAnimationFrame, read all rectangles before writing any style to avoid forced layouts, and write the position as a transform on a position: fixed bubble.
Testing with fake timers
Real timers make tests slow and flaky. Unit-test the state machine as a pure function (every event in every state), then test the component with fake timers, so the test controls the clock.
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
// The bubble layer sets data-open while the bubble is shown.
const bubble = () => screen.queryByText("Delete", { selector: ".tooltip[data-open]" });
test("hover waits 500 ms", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<App />);
await user.hover(screen.getByRole("button", { name: "Delete" }));
act(() => vi.advanceTimersByTime(499));
expect(bubble()).toBeNull();
act(() => vi.advanceTimersByTime(1));
expect(bubble()).toBeInTheDocument();
});
test("keyboard focus opens at once, Escape keeps focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<App />);
await user.tab(); // first stop is the Delete button
expect(bubble()).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(bubble()).toBeNull();
expect(screen.getByRole("button", { name: "Delete" })).toHaveFocus();
});
- Wiring: assert computed names and descriptions, like jest-dom's
expect(boldButton).toHaveAccessibleDescription("Ctrl+B"). - What jsdom can't do: it has no layout, so every rectangle is zero, and newer APIs like popovers may be missing. Test flip, shift and clipping in a real browser (Playwright, or Vitest's browser mode) with triggers at each edge and inside a scrolling panel.
- Accessibility checks: run axe on pages with icon buttons to catch missing names, and do a manual screen reader pass for both modes.
- Visual tests: screenshot each placement and the flipped and shifted cases to catch arrow regressions.
Check your understanding · Exercise 8
Which statements about touch, server rendering and testing are right? Select all that apply.
Running it in the interview
A tooltip question is usually 30 minutes, sometimes a warm-up before a bigger component. The trap is spending 20 minutes on positioning math; interviewers want breadth first (every kind of user), then depth on two or three decisions.
Draw three things, in this order: the wireframe (it anchors the vocabulary), the state machine (it shows you think in states, not handlers), and the architecture with one shared controller (it answers the scale question before it's asked). Say the rest out loud.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 4 | Define it and ask scope questions. | "Short, non-interactive, describes its trigger, never essential." Ask about icon-only buttons, touch, dialogs, big tables. |
| 4 to 10 | Accessibility and WCAG 1.4.13. | Label versus description, the hidden span, Escape, hoverable, persistent. Draw the wireframe with the trigger, bubble and arrow. |
| 10 to 16 | Timing. | Draw the state machine. Name the numbers: 500 ms open, 0 ms on focus, 100 ms close, a 300 ms warm-up window, and the safe area. |
| 16 to 22 | Positioning and layers. | The pipeline: offset, flip, shift, arrow, hide. Then the top layer with a popover, inside the dialog when one is open. |
| 22 to 27 | Architecture and API. | One shared controller with delegation for the 6,000-trigger table. The wrapper API with content: string. |
| 27 to 30 | Wrap up. | Touch (no tooltip on tap, toggletip), reduced motion, useId for server rendering, fake-timer tests. |
Follow-up questions to expect
Think first
"Design wants a 'Learn more' link inside the tooltip. Can we support that?"
Not in a tooltip: keyboard users can never reach the link, and screen readers read it as plain description text.
Offer a toggletip, which opens on click and can hold a link because focus can move into it. Typing content as a string already enforces this.
Think first
"A user opens a menu and hovers an icon inside it. The menu closes. Why, and how do you fix it?"
The tooltip is probably shown as popover="auto", and opening an auto popover closes other auto popovers that aren't its ancestors, like the menu.
Use popover="hint", which leaves menus open, or manual where hint isn't supported.
Think first
"Tooltips in our toolbar flicker when people move the mouse quickly across it. What's wrong?"
Either there's no open delay, or each button runs its own timers and they leak into each other.
Fix it with one controller: a 500 ms delay when cold, instant switching when warm, one active tooltip, and timers cleared on every state change. Add a fake-timer test that hovers 3 buttons 40 ms apart and expects no bubble.
Check your understanding · Exercise 9
You have 5 minutes left and haven't mentioned these. Which are worth the time? Select all that apply.
Summary
- A tooltip is a short, non-interactive description of its trigger. Links and buttons go in a popover or toggletip, and essential information never lives only in a tooltip.
- Decide label versus description per use:
aria-labelfor icon-only buttons,aria-describedbyto always-present hidden text for extra detail, and anaria-hiddenvisual bubble so nothing is read twice. - WCAG 1.4.13 turns into code: Escape dismisses without moving focus, the pointer can move onto the bubble, and there's no auto-hide timer.
- Timing is a state machine: about 500 ms before showing on hover, none on keyboard focus with
:focus-visible, a 100 ms grace delay with a safe area, and a warm-up group so a toolbar feels instant once one tooltip shows. - Positioning is a pipeline (offset, flip, shift, arrow, hide) re-run on scroll and resize only while open. CSS anchor positioning is the future; a JavaScript engine is the safe path today.
- Render in the top layer with the Popover API,
popover="hint"where supported andmanualotherwise, inside the open dialog when there is one. That ends clipping, z-index wars and inertness bugs. - One shared controller and one bubble with event delegation keeps a 6,000-trigger table cheap.
- Touch has no hover: a tap does the action, and anything users need is visible. Use
useIdfor ids and fake timers for tests.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA Authoring Practices: Tooltip pattern The baseline ARIA markup and keyboard rules; marked as work in progress.
- Understanding WCAG 2.2: Content on Hover or Focus (1.4.13) Dismissible, hoverable and persistent, with examples and exceptions.
- MDN: Popover API The top layer, popover types and showPopover().
- Chrome for Developers: popover=hint Why tooltips need a second popover stack that doesn't close menus.
- Floating UI: computePosition The middleware pipeline: offset, flip, shift, arrow, hide.
- Floating UI: useHover and safePolygon Hover delays and the safe area between trigger and floating element.
- MDN: CSS anchor positioning anchor-name, position-area and position-try-fallbacks.
- MDN: Using interest invokers The interestfor attribute, shipped in Chromium, for hover and focus triggered popovers.
- Open UI: Interest invokers explainer The design and open questions behind interestfor.
- Radix UI: Tooltip A production tooltip: delayDuration 700 ms, skipDelayDuration 300 ms, hoverable content.
- Inclusive Components: Tooltips and Toggletips When to use a tooltip, when a toggletip, and how each is announced.
- React: useId Ids that match between server rendering and hydration.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.