cf.completefrontend
My progress
SYSTEM DESIGN · #4

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.

QuestionAnswer we assumeWhat 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: hidden or 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 title attribute styling: we won't use title at 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.

  1. Four kinds of input. Mouse, keyboard, finger and screen reader each need the same text by a different path.
  2. 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.
  3. 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.
  4. Layers. The bubble must appear above cards, sticky headers, menus and modal dialogs, and z-index: 9999 doesn't always win.
  5. 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?

The native title attribute

Put title="Delete" on the button. The browser shows its own small box after a moment of hover.

Pros

  • Zero code, zero bytes.
  • Gives the element an accessible description (or a name, if it has none).

Cons

  • Doesn't appear on keyboard focus in any major browser, so keyboard users never see it.
  • Doesn't appear on touch screens at all.
  • You can't style it, set its delay, or control where it goes.
  • You can't make it dismissible or hoverable. WCAG 1.4.13 exempts browser-drawn title boxes only because authors can't change them, not because they work well.

CSS only: a hidden child shown with :hover

Put a span inside the button and show it with .btn:hover .tip, .btn:focus-visible .tip { opacity: 1 }.

Pros

  • No JavaScript, and works on keyboard focus with :focus-visible.
  • Cheap even with thousands of triggers.

Cons

  • The bubble lives inside the trigger, so any ancestor with overflow: hidden cuts it off.
  • No flip or shift, no delay, no warm-up group, and no Escape.
  • The gap between trigger and bubble closes it, so it isn't hoverable.

A JavaScript tooltip component

A component listens for pointer and focus events, runs timers, computes a position that fits, and renders the bubble in a layer above the page.

Pros

  • Covers every input, timing rule and layer problem, consistently across the product.

Cons

  • Costs a few KB and some care with performance.
  • Has to be built carefully once, which is what a design system is for.

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 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.

ChapterDecisionOur pick
3How screen readers get the text, and how we meet WCAG 1.4.13aria-label for icon-only buttons, aria-describedby for extra detail; Escape, hoverable, persistent
4When it opens and closesHover with a 500 ms delay, keyboard focus at once, a warm-up group, a 100 ms close delay and a safe area
5Where it goesA placement engine with offset, flip, shift and arrow, re-run on scroll and resize
6Which layer it renders inThe top layer through the Popover API, with a portal as the fallback
7One instance or manyOne shared controller and one bubble, with event delegation
8The APIA small Tooltip wrapper plus a provider, with props for placement, delay and truncation
9Touch, motion, server rendering, testsNo 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.

Wireframe of a mail app with a toolbar of three icon buttons: Archive, Spam and Delete. Delete is hovered. Its tooltip bubble with the text Delete sits below it, flipped from the top because the toolbar is at the top of the window, with a small arrow pointing up at the button and an 8 pixel gap. Below are rows of the message list. Labels on the right name the trigger, which carries aria-label Delete, and the bubble, which is aria-hidden and rendered in the top layer. A note says label mode uses aria-label and description mode uses a hidden span.mail.example/inboxarchspamdelDeletetriggeraria-label="Delete"bubblearia-hidden, top layerlabel: aria-labeldetail: hidden spanmessage list
The trigger carries the accessible text; the bubble is only a picture of it. Here the toolbar is at the top, so the bubble flipped below the button, and its arrow still points at the trash icon.

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?

The floating bubble, only while it's open

The bubble mounts on open, and only then does the trigger get aria-describedby with the bubble's id.

Pros

  • Simple. Close to the APG markup.
  • Nothing extra in the DOM while closed.

Cons

  • Racy: the screen reader reads the button the moment focus lands, so a bubble that mounts a few milliseconds later may be missed.
  • In a screen reader's browse mode, users move through the page without moving focus, so the description never exists.

Hidden text that always exists, next to the trigger

The component renders span hidden with an id and the tooltip text right after the trigger, from the first server render, and the trigger's aria-describedby points at it. The visible bubble is a visual copy with aria-hidden="true".

Pros

  • The description is there before focus, in browse mode, and while JavaScript loads.
  • Hidden elements still count when referenced by id: the accessible name and description rules include them on purpose.

Cons

  • One extra span per trigger, with no listeners.
  • The text exists in two places, so both must come from the same prop.

Use aria-label for names, nothing for descriptions

Label mode sets aria-label on the trigger. Description mode is treated as visual only.

Pros

  • Smallest possible wiring.

Cons

  • Screen reader users lose every description, like the keyboard shortcut.

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.

html
<!-- 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>
The markup each mode produces. The bubble itself is rendered elsewhere (chapter 6).

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:

ConditionWhat WCAG saysHow 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.

Sequence for a keyboard user on the Bold button, with four columns: User, Bold button, Controller and Bubble. The user presses Tab. The button sends focusin to the controller, which checks focus-visible. The controller shows the bubble with no delay, and the user sees Ctrl+B. The user presses Escape. The controller hides the bubble and stops the event. A note says the screen reader reads Bold, button, Ctrl+B on focus from aria-describedby. Another note says focus stays on Bold and a surrounding dialog stays open.UserBold buttonControllerBubbleTabfocusin, visibleshow, 0 mssees Ctrl+BEscapehide, stop eventreader on focus:Bold, button, Ctrl+Bfocus stays on Boldthe dialog stays open
The screen reader doesn't wait for the bubble: it reads the description from 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 pointerenter and pointerleave, ignoring pointerType touch. Touch screens also fire compatibility mouse events after a tap, which is why mouseenter tooltips stick open on phones.
  • Keyboard focus: listen to focus and blur, but open only if trigger.matches(":focus-visible"). Browsers match :focus-visible when 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

TimerOur valueWhy
Open delay (hover)500 msLong 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 msMoving focus is a deliberate act, one stop at a time. A delay only slows keyboard users down.
Close delay (grace)100 msGives 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 msAfter 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.

  1. Cold group: the pointer enters Archive, waits 500 ms, and "Archive" shows. The group is now warm.
  2. The pointer moves to Spam. Archive closes at once and Spam opens with no delay or animation, like one bubble that moved.
  3. The pointer leaves the toolbar. The last tooltip closes, and the group stays warm for 300 ms.
  4. 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.

Pointer path
Open delay
Close grace
Timeline of 1200 ms for the path "quick sweep", with an open delay of 500 ms, a close grace of 100 ms, the warm-up group on and the safe area on. The pointer row shows B1 from 0 to 40 ms, B2 from 40 to 80 ms, B3 from 80 to 120 ms, B4 from 120 to 160 ms, B5 from 160 to 200 ms, B6 from 200 to 240 ms, B7 from 240 to 280 ms, B8 from 280 to 320 ms, away from 320 to 1200 ms. The bubble row shows no bubble opening. Result: no bubbles flashed while passing by.pointerbubbleawaynothing shown0 ms500 ms1000 msno bubbles flashedwhile passing by
Crossing 8 buttons in 320 ms should show nothing. Set the open delay to 0 to see why the delay exists.

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?

A grace timer only

On pointerleave, wait 100 ms before closing, and cancel if the pointer enters the bubble.

Pros

  • Tiny and easy to reason about.

Cons

  • Slow hands and long diagonal trips take longer than 100 ms, and a longer timer makes tooltips feel sticky.

An invisible bridge

A transparent pseudo-element on the bubble fills the gap toward the trigger.

Pros

  • Pure CSS. Covers straight moves across the gap.

Cons

  • Covers only a rectangle; diagonal trips toward a shifted bubble leave it.

A safe area (safe triangle)

When the pointer leaves the trigger, build a triangle from the exit point to the bubble's two near corners. While pointermove stays inside it, keep the tooltip open; leaving it closes the tooltip.

Pros

  • Follows how people actually move a mouse, including diagonal moves.
  • Floating UI ships this as safePolygon(), and Radix's tooltip builds a similar grace area, so it's a known, tested technique.

Cons

  • A pointermove listener and some geometry, plus a grace timer for pointers that stop moving.

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.

A Settings trigger near the top, and below it a bubble that shifted to the right, so its arrow sits near its left end under the trigger. A dashed triangle joins the point where the pointer left the trigger to the two top corners of the bubble. One pointer path travels diagonally inside the triangle to the bubble and the tooltip stays open. Another path leaves the trigger to the right, outside the triangle, and the tooltip closes after 100 ms. A note says inside the area the tooltip stays open, and a pointer that stops moving gets a 100 ms grace.SettingstriggerOpens your settingsbubble, shifted rightsafe arealeaves: closesinside: stays openstopped: 100 ms grace
The triangle follows the pointer's real path: diagonal, toward wherever the bubble ended up after shifting. A plain gap bridge would only cover the strip straight below the trigger.

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.

State machine for one tooltip with four states. Closed moves to opening on hover, which starts a 500 ms timer. Opening moves to open when the timer is done. Closed moves straight to open on keyboard focus, or on hover when the group is warm. Open moves to closing on leave or blur, which starts a 100 ms grace timer. Closing moves back to open if the pointer reaches the bubble or stays in the safe area, and to closed when the grace is over. A note says Escape or a press moves to dismissed, which stays shut until leave or blur.closednothing shownopening500 ms timeropenbubble shownclosing100 ms gracehover500 msfocus, or warm hoverleave, bluronto bubblegrace overEscape, press:dismissed untilleave or blur
Each timer belongs to one state, so leaving a state always clears its timer. Leaving the trigger while opening simply goes back to closed, and the dismissed state keeps Escape from being undone by a 1 px mouse nudge.
ts
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";
  }
}
The transition function. Timers are started and cleared by whoever runs it, based on the state entered.

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.

Sequence for the first hover of the session, with four columns: User, Controller, Positioner chunk and Bubble. The user's pointer moves over Bold. The controller enters opening and waits 500 ms. On this first open it calls import for the positioner chunk over the network. The chunk replies with the module. On a failure, shown dashed in red, the reply is load failed. The controller then shows the bubble with its text and coordinates, either from the positioner or from a simple fallback below the trigger. The bubble paints and the user sees Bold.UserControllerPositionerBubblepointeroveropening, 500 msimport()moduleor: load failedtext, x, y, showBold visiblefailed: place below,clamp, no arrowlater opens: no import,module is cached
The 500 ms delay hides the download: the 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 top or bottom-start. top is 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.

  1. Place: centered above means x = 1,256 - 90 = 1,166 and y = 8 - 8 - 32 = -32. The top 32 px are off screen.
  2. Flip: below has 680 px of room, so flip to bottom: y = 40 + 8 = 48.
  3. 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.
  4. 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.

The positioning pipeline as five boxes in a row, each feeding the next. The input is placement top, centered, at x 1166 and y minus 32. Offset 8 adds the 8 pixel gap. Flip moves it from top to bottom. Shift moves x by minus 74. Arrow puts the arrow 164 pixels from the bubble's left edge. Hide checks the trigger is still visible. The output is bottom, x 1092, y 48, arrow 164, visible.in: top, x 1166, y -32offset(8)gap 8 pxflip()to bottomshift()x -74arrow()at 164 pxhide()visibleout: bottom, x 1092, y 48, arrow 164
Each step reads the coordinates the previous step produced and returns new ones. Flip has to come before shift, the arrow after shift because it depends on the final x, and hide last.

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: hidden or overflow: auto cuts 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: paint or will-change: transform becomes the reference box for position: fixed children, 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.

Preferred side
Renders
A screen 600 by 280 with a sticky header and a side panel on the right that scrolls and clips its content. The trigger is at x 168 inside the panel, which is scrolled by 0 px. Preferred side top; flip on, shift on, hide on; the bubble renders inline. It is placed on the top, shifted 40 px. The panel's overflow cuts part of the bubble off, even where the screen has room. Switch to the top layer.sticky headerpage contenticonplaced: topshift: 40 pxrenders: inlinebubble clipped
The panel's overflow cuts part of the bubble off, even where the screen has room. Switch to the top layer.

Choosing the engine

Decision What computes the position?

Hand-written math in the component

getBoundingClientRect() on the trigger and the bubble, then a few ifs for edges.

Pros

  • No dependency.

Cons

  • Every team rewrites flip, shift, arrow, scroll tracking and right-to-left, and gets some wrong.

A JavaScript positioning library (Floating UI)

computePosition(trigger, bubble, { placement, middleware }) runs offset(8), flip(), shift({ padding: 8 }), arrow({ element }) and hide() in order. autoUpdate re-runs it on scroll, resize and size changes.

Pros

  • Works in every browser in use today.
  • One job per step, in order, with exact shift and arrow position.

Cons

  • A few KB of JavaScript (it can load on first open).
  • Runs on the main thread, so on a busy page a scroll update can lag a frame behind.

CSS anchor positioning

The trigger gets anchor-name: --tip-3; the bubble gets position-anchor: --tip-3, position-area: top and position-try-fallbacks: flip-block. The browser flips and follows scrolling itself.

Pros

  • Zero JavaScript for placement, and no lag when scrolling.
  • Works with the Popover API's top layer.

Cons

  • Support arrived engine by engine: Chromium 125 in 2024, Safari 26 in 2025, Firefox 147 in January 2026. Users on older versions still need a fallback.
  • Flip is easy; sliding along the edge and moving the arrow to match are harder in CSS today.

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.

Two areas. On the left, the document: a sticky header with z-index 100, a card with a transform that forms its own stacking context, holding a bubble with z-index 9999 that is trapped inside the card, and the page content. On the right, the top layer: a dialog opened with showModal first, and a tooltip popover shown second, which paints above the dialog. An arrow marks that the later entry stacks above.documentsticky headerz-index: 100card, transformbubblez-index: 9999, trappedpage contenttop layertooltip popovershown 2nd: on topdialogshowModal(), 1ststacks above
Inside the document, 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?

Inline, next to the trigger

Render the bubble as a sibling of the trigger, positioned absolutely.

Pros

  • Simplest, inherits theme and direction, and scrolls with the trigger for free.

Cons

  • Clipped by any overflow ancestor, trapped in every stacking context above it, and broken by transform ancestors.

A portal to the end of body

createPortal renders the bubble at the end of body with position: fixed and a high z-index.

Pros

  • Escapes clipping and most stacking contexts.
  • The long-standing approach; works in every browser.

Cons

  • Still loses to the top layer: inside a modal dialog, it renders under the dialog and its backdrop.
  • A modal dialog makes everything outside it inert, so the bubble ignores the pointer and can't be hovered.
  • Needs a z-index scale for the whole product.

The top layer, with the Popover API

The bubble is an element with the popover attribute. showPopover() moves it into the top layer, above everything shown before it, including an open modal dialog. hidePopover() closes it.

Pros

  • No clipping, no stacking contexts, no z-index scale, and ancestor transforms don't matter.
  • The Popover API is supported in all major browsers since 2024.

Cons

  • Inertness still applies: inside a modal dialog, the bubble element must be a descendant of the dialog to stay hoverable.

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:

ValueBehaviorFor a tooltip?
autoLight 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.
manualNo light dismiss, closes nothing else. You open and close it yourself.Yes, and it works everywhere the Popover API does. We handle Escape ourselves.
hintMade 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.
ts
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();
}
Feature detection, run on the client when the provider mounts. Unknown popover values are treated as manual, so reading the value back tells us if hint is supported.

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?

One full instance per trigger

Each Tooltip has its own state, listeners, timers and bubble element.

Pros

  • Easy to write and read.

Cons

  • Cost grows with the number of triggers, not with what's on screen.
  • Delay groups need shared state anyway.

One lazy instance per trigger

Each Tooltip attaches listeners but mounts its bubble only when it opens.

Pros

  • No hidden bubbles in the DOM; a common library design.

Cons

  • Still one component and several listeners per trigger: 6,000 of each in our table.

One shared controller and one bubble

A TooltipProvider at the app root owns a single controller and a single bubble element. Triggers carry a data-tooltip id. The controller listens once on the document for pointerover, pointerout, focusin and focusout (they bubble, unlike pointerenter and focus), plus pointerdown and keydown for press and Escape. It finds the trigger with event.target.closest("[data-tooltip]") and looks up its content in a registry.

Pros

  • 6 listeners for the whole app, whatever the number of triggers.
  • Delay groups are natural, and only one tooltip can ever be open, which is correct anyway.

Cons

  • pointerover fires again when moving between children of the same trigger, so compare against the current trigger.
  • Triggers inside a closed shadow root need their own provider.

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 tooltip's design. On the left, in the React tree: the TooltipProvider, which holds settings; 6,000 Tooltip wrappers, each adding aria attributes and a data attribute to its trigger; and the registry, a map from id to entry. On the right, outside React: the controller with 6 document listeners and the state machine, the positioner loaded lazily, and the one bubble popover element. The user's input reaches the Tooltip triggers; events bubble to the controller; the provider creates the controller; the registry gives it content; the controller calls the positioner and sends text and coordinates to the bubble.userReact treeTooltipProvidersettings, layerTooltip x 6,000aria, data-tooltipRegistryMap: id to entryoutside ReactPositionerlazy: flip, shiftController6 listeners, machineBubbleone popover elementeventscreatescontentplace()text, x, y
Everything that runs per hover lives on the right, outside React, so 6,000 triggers cost almost nothing. The left side only renders markup and fills the registry.

The parts and their jobs

PartResponsibilityWhy this boundary
TooltipProviderCreates 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 wrapperAdds 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.
RegistryA Map from tooltip id to content and options.Lets the controller find content without React re-renders of the triggers.
ControllerDelegated 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.
PositionerMeasures 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 layerThe 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.

Data flow in two rows. Top row, at load time: the server sends strings from the translation bundle into the server HTML, which already holds aria-label and the hidden spans; hydration runs the Tooltip wrappers, which register an entry in the registry. Bottom row, at runtime: the user's pointer or focus input reaches the controller, which looks up the entry by id in the registry, asks the positioner to measure rectangles, and gives x and y to the bubble, which paints for the user.Serveri18n bundleSSR HTMLaria-label, spanTooltipregisterRegistryid to entrystringshydrateentryuserControllerstate machinePositionerrectsBubblepopoverinputlookup by idmeasurex, ypaint
Text flows down once, at load time, and lands in the HTML before any JavaScript runs. Events flow at runtime, and only the controller, the positioner and the bubble take part.

The data model

ts
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>;
Everything the controller keeps. Note what isn't here: no per-trigger React state.
DataWhere it livesWhy
Tooltip textProps, from the translation bundle; copied into the registry and the hidden spanThe server HTML must contain it for screen readers and for no-JavaScript cases.
Which tooltip is open, and its statusThe controller, outside ReactChanges many times per second during hover; keeping it out of React avoids re-rendering triggers.
Warm-up deadlineThe controllerShared by the whole group; only the controller needs it.
Final placement and coordinatesThe bubble element's style, plus data-placement and data-open attributesRead by CSS for the arrow's side and the fade, and by tests; never needed in React state.
Delay settingsProvider props, with per-tooltip overridesOne 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?

A wrapper around one child

Tooltip content="Delete" wraps exactly one element and merges the attributes it needs (data-tooltip, aria-label or aria-describedby) onto it.

Pros

  • One line per use, with any trigger: Button, IconButton, a link, a table cell.

Cons

  • Has to merge props onto a child it doesn't own, so the child must pass unknown props through to its DOM element.

A hook that returns props

const tip = useTooltip({ content: "Delete" }), then spread tip.triggerProps on the element.

Pros

  • Full control, no prop merging.

Cons

  • Easy to forget the spread or put it on the wrong element.

Compound components

Tooltip.Root, Tooltip.Trigger, Tooltip.Content as three nested parts.

Pros

  • Very flexible.

Cons

  • Invites links and images in Tooltip.Content, exactly what a tooltip must not hold, and needs three components for one line of text.

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.

tsx
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>
The API as product teams use it.
  • content is 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.
  • mode is explicit. In development builds, if mode is "description" and the mounted trigger has no accessible name, the component logs a warning pointing at mode="label".
  • No open prop by default. A controlled open invites 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?

Wrap the disabled button in a span

Tooltip detects a disabled child and wraps it in a span that takes the pointer events. Some libraries add tabindex="0" to the span for keyboard users.

Pros

  • Works with existing disabled buttons.

Cons

  • A focusable span has no role, so screen readers announce a meaningless stop, and the button inside still isn't reachable.
  • The extra element can shift layout.

Use aria-disabled instead of disabled

The button gets aria-disabled="true". It stays focusable and receives events, and our Button ignores activation while it's set and styles itself as disabled.

Pros

  • Keyboard and screen reader users can reach it and hear "Publish, button, unavailable" plus the reason.
  • No wrapper, no layout change.

Cons

  • The Button component must block activation itself, including form submission.

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.clientWidth at 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-describedby and 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?

Show the tooltip on tap

The first tap shows the tooltip, a second tap activates the button.

Pros

  • Touch users see the same text.

Cons

  • Every button now needs two taps, slowing down users who already know the icon, and a tap means something different across the product.

Show it on long press

Press and hold to show the tooltip; release to hide it without activating the button. Android does this for its own icon buttons.

Pros

  • Doesn't change what a tap does. Familiar to Android users.

Cons

  • Hard to discover, and competes with the browser's own long-press actions, like text selection and link previews.

Don't rely on tooltips for touch

Tap does the action, with no tooltip. Icon-only buttons get visible labels on small screens where space allows, and information that matters goes in a toggletip.

Pros

  • Nothing important is hidden behind hover. Taps keep their meaning.

Cons

  • Designers need to plan labels for small screens instead of relying on the tooltip.

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 opacity and transform, 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: none needs @starting-style and transition-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, so aria-describedby matches the hidden span after hydration. Counters or Math.random() produce mismatches.
  • Accessible text in the server HTML: aria-label and 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.

tsx
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();
});
Vitest and Testing Library. user-event must be told to advance the fake clock.
  • 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.

MinutesWhat to doWhat to draw or say
0 to 4Define it and ask scope questions."Short, non-interactive, describes its trigger, never essential." Ask about icon-only buttons, touch, dialogs, big tables.
4 to 10Accessibility 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 16Timing.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 22Positioning 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 27Architecture and API.One shared controller with delegation for the 6,000-trigger table. The wrapper API with content: string.
27 to 30Wrap 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?"

Think first

"A user opens a menu and hovers an icon inside it. The menu closes. Why, and how do you fix it?"

Think first

"Tooltips in our toolbar flicker when people move the mouse quickly across it. What's wrong?"

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-label for icon-only buttons, aria-describedby to always-present hidden text for extra detail, and an aria-hidden visual 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 and manual otherwise, 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 useId for 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next