Tooltip
A short label on hover or focus. Text only, never interactive, and never the only place information exists.
Also called Hint, Title Tip, Label Tip — in this system all of them are Tooltip.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Naming icon-only controls
The primary use. Every button still carries its own aria-label — the tooltip is the sighted-pointer version of the same information, not a replacement for it.
Placement and collision
Top by default because it is out of the pointer’s path. Near a viewport edge it flips to the opposite side rather than being clipped.
Shortcuts belong here
The tooltip is where most people discover a keyboard shortcut, because it appears exactly when they are about to use the mouse instead.
Never for required information
The left version hides the constraint behind a hover that does not exist on a phone. The right one states it where everyone can read it.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
An inverted surface, one line of text, an optional shortcut, and a 6px gap that keeps it clear of the trigger’s focus ring.
- SurfaceInverted, --ds-fg
Inverted rather than another elevated panel, so it never reads as a menu or a popover the user could click into.
- Padding6px 10px
Tight. A tooltip that looks roomy looks clickable, and it is not.
- Type12px, 1.4 leading
One step below body. It is supplementary information and should not compete with the content behind it.
- Max width16rem, ~2 lines
The ceiling is the point. Anything needing three lines is a Popover, and forcing the limit keeps the content honest.
- Offset6px from the trigger
Enough to clear the trigger’s focus ring, close enough that the association is unambiguous.
- Delay400ms, 0 once warm
The pointer crossing a toolbar must not trigger anything. Once one tooltip is open, the next appears instantly.
- ShortcutKbd, inverted, trailing
The most valuable optional content a tooltip can carry: it appears exactly when the user is reaching for the mouse instead.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-fg | — | Tooltip surface — deliberately inverted |
| --ds-fg-inverse | — | Tooltip text |
| --ds-border-strong | — | The dotted underline on a text trigger |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding | Tooltip padding | |
| offset | Gap from the trigger |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Tooltip corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e3 | — | Lifts it clear of the surface behind |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-caption | Tooltip text |
Motion
| Token | Value | Used for |
|---|---|---|
| open delay | Hover intent | |
| --duration-fast | Fade and 2px rise on enter |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Label gap | Type | Max width | When to use |
|---|---|---|---|---|---|---|---|
| Tooltip | 26px single line | 6px 10px | 8px | — | 12px | 16rem | One or two lines. Three means it should be a Popover. |
| Offset | — | — | — | 6px | — | — | From the trigger, on every side. Clears the focus ring. |
| Viewport margin | — | — | — | 8px | — | — | Minimum distance from any viewport edge before flipping. |
| Shortcut | 18px | — | — | — | — | — | A Kbd on the inverted surface, at the trailing edge. |
<button aria-label="Archive deployment">
+ tooltip content="Archive deployment"Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Inverted text on the tooltip surface must reach 4.5:1. The inverted pair is chosen for exactly this, in both themes.
- A Kbd inside a tooltip sits on an already-inverted surface — its own border and text must be re-derived, not reused from the light-surface version.
- A dotted underline on a text trigger owes 3:1; it is the only signal that the text has a tooltip at all.
Keyboard
| Tab | Focuses the trigger and shows the tooltip. Hover-only tooltips do not exist for keyboard users. |
| Esc | Dismisses the tooltip while leaving focus on the trigger. Required by 1.4.13. |
| Shift + Tab | Moves away and hides it. The tooltip is never itself focusable. |
Screen readers
- A tooltip is announced as part of the trigger’s description, not as a separate region. It should never interrupt.
- Never rely on a tooltip for the trigger’s name. An icon button with only a tooltip announces as "button" with no indication of what it does.
- Keep the content short. It is read out in full every time the trigger receives focus, so a paragraph becomes a paragraph re-read on every tab pass.
Focus & touch
- The tooltip never takes focus — it is a description, not a destination. WCAG 1.4.13 additionally requires it to be dismissible with Escape, to stay visible while the pointer moves onto it, and not to obscure the trigger.
- Tooltips do not exist on touch — there is no hover. Do not attempt a long-press substitute: it conflicts with the platform’s own text-selection and context-menu gestures. Instead, ensure every tooltip’s content is available another way: a visible label, a description under the field, or a Popover behind an explicit help control.
| Attribute | Applied to | Notes |
|---|---|---|
| role="tooltip" | The tooltip element | With an id referenced by the trigger. |
| aria-describedby | The trigger | When the tooltip supplements an existing label. This is the normal case. |
| aria-labelledby | The trigger | Only if the tooltip genuinely is the name — and even then, prefer a real aria-label so touch users get it too. |
| aria-hidden | The tooltip when closed | Or remove it from the DOM. A hidden tooltip left in the accessibility tree is announced with no context. |
| tabindex="0" | A wrapper around a disabled control | Disabled elements do not fire pointer or focus events, so the tooltip needs a reachable host. |
Example usage
1import { Tooltip } from '@/ui/Display'23// The label is on the BUTTON. The tooltip repeats it for sighted pointer4// users — it is never the accessible name.5<Tooltip content="Archive deployment" shortcut="E">6 <IconButton label="Archive deployment" icon={<Archive />} />7</Tooltip>89// A disabled control fires no pointer or focus events, so the tooltip needs10// a reachable host — and the reason is exactly what makes the dead end useful.11<Tooltip content="You need write access to deploy">12 <span tabIndex={0} className="inline-flex">13 <Button disabled>Deploy</Button>14 </span>15</Tooltip>1617// The delay is doing real work: a pointer crossing a toolbar must not trigger18// anything, but once one tooltip is open the next should be instant.19const warm = React.useRef(false)20const show = () => {21 const delay = warm.current ? 0 : 40022 timer.current = window.setTimeout(() => {23 setOpen(true)24 warm.current = true25 }, delay)26}27const hide = () => {28 window.clearTimeout(timer.current)29 setOpen(false)30 // Grace period: scanning a toolbar stays instant for a moment.31 window.setTimeout(() => { warm.current = false }, 300)32}3334// WCAG 1.4.13: dismissible without moving the pointer or focus.35React.useEffect(() => {36 if (!open) return37 const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)38 document.addEventListener('keydown', onKey)39 return () => document.removeEventListener('keydown', onKey)40}, [open])Framework-free HTML
<!-- aria-label is the name. The tooltip DESCRIBES; it does not name. -->
<button
type="button"
aria-label="Archive deployment"
aria-describedby="tip-archive"
>
<svg aria-hidden="true">…</svg>
</button>
<div id="tip-archive" role="tooltip">
Archive deployment
<kbd>E</kbd>
</div>
<!-- A disabled control fires no events: give the tooltip a reachable host. -->
<span tabindex="0" aria-describedby="tip-deploy">
<button type="button" disabled>Deploy</button>
</span>
<div id="tip-deploy" role="tooltip">You need write access to deploy</div>CSS
[role='tooltip'] {
position: absolute;
z-index: 90;
/* Inverted, so it never reads as a panel you could click into. */
background: var(--ds-fg);
color: var(--ds-fg-inverse);
padding: 6px 10px;
border-radius: var(--radius-md);
font-size: 12px;
line-height: 1.4;
/* The ceiling is the point: three lines means it should be a Popover. */
max-inline-size: 16rem;
box-shadow: var(--shadow-e3);
/* Never intercepts the pointer — a tooltip must not block what it labels. */
pointer-events: none;
animation: tooltip-in 120ms ease-out both;
}
@keyframes tooltip-in {
from { opacity: 0; translate: 0 2px; }
to { opacity: 1; translate: 0 0; }
}
/* A Kbd on an already-inverted surface needs its own colours, not the ones
tuned for a light panel. */
[role='tooltip'] kbd {
border-color: rgb(255 255 255 / 0.25);
background: rgb(255 255 255 / 0.1);
color: inherit;
}
/* The only signal that a run of text has a tooltip at all. */
[data-tooltip-trigger='text'] {
border-block-end: 1px dotted var(--ds-border-strong);
cursor: help;
}
@media (prefers-reduced-motion: reduce) {
[role='tooltip'] { animation: none; }
}
/* There is no hover here, and long-press belongs to the platform. */
@media (pointer: coarse) {
[role='tooltip'] { display: none; }
}Component API
Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
| content* | ReactNode | — | Text only. Anything interactive belongs in a Popover. |
| children* | ReactElement | — | A single focusable element. Wrap a disabled control in a focusable span. |
| side | 'top' | 'bottom' | 'left' | 'right' | 'top' | Top is out of the pointer’s path. Flips automatically near a viewport edge. |
| delay | number | 400 | Open delay in ms. Drops to 0 once another tooltip has recently been open. |
| shortcut | string | — | Rendered as a Kbd at the trailing edge. The most valuable thing a tooltip can carry. |
Professional tips
- Use sentence case and no full stop. A tooltip is a label, not a sentence, and the full stop makes it read as truncated prose.
- Show the truncated text in full when a label is clipped — it is the one case where the tooltip and the label are legitimately the same string.
- Keep the tooltip out of the pointer’s path. Top placement is the default because bottom placement sits exactly where the cursor is heading.
- Do not animate position, only opacity and a 2px rise. A tooltip that slides between triggers draws far more attention than it deserves.
- Audit your tooltips periodically: if removing all of them would break the product, some of them were carrying content that belongs in the page.
Performance
- Render one tooltip element and re-point it, rather than mounting one per trigger. A table with two hundred icon buttons should not have two hundred hidden tooltips.
- Compute placement on open, not on every scroll frame. Anchoring maths in a scroll listener is the usual cause of jank in dense tables.
- Clear the open timer on unmount. A tooltip that appears after its trigger has gone is a small but memorable bug.
- Skip the component entirely on coarse pointers — do not ship the listeners, the timers or the markup where hover does not exist.
Common mistakes
- Using the tooltip as the accessible name, leaving icon buttons anonymous for screen-reader and touch users.
- Hover-only, so keyboard users never see the shortcut it contains.
- Interactive content inside, which can never be reached before the tooltip closes.
- Required information hidden behind hover, which does not exist on touch.
- No Escape dismissal, failing WCAG 1.4.13.
- pointer-events left enabled, so the tooltip blocks the control it describes.
- Repeating a visible label, training users that tooltips are noise.
- A paragraph of text that cannot be selected, copied, or read at leisure.
Real-world recommendations
- Tooltips are where keyboard shortcuts get discovered. Products that put them there see measurably higher shortcut adoption than products that document them on a help page.
- In dense tables, tooltips on truncated cells are worth more than any amount of column-width tuning — the user gets the full value without leaving the row.
- The 400ms delay is worth testing on your own product. Toolbars with tightly packed controls often want longer; a single isolated icon can afford shorter.
- If a designer asks for a tooltip with a link in it, the answer is a Popover. It is the most common request that this component cannot fulfil.