User Interface (UI)
Let's start with how a tooltip looks: its parts, the two jobs it can do, where it opens, its states and the variables you can set.
Anatomy
- Trigger — The element the tooltip is about, like a Button. It's yours, it takes focus and it keeps its own name.
- Tooltip — A
<span>that holds the text, on a filled box with rounded corners, in the top layer while it's open. - Offset — The gap between the trigger and the tooltip, which the pointer can cross without closing it.
- Arrow — On by default. A small point on the edge that faces the trigger, in the tooltip's fill, hidden from assistive technologies.
Every tooltip has all four parts, unless you turn the arrow off. The tooltip holds plain text only, so there's never anything in it to focus or click (see Keep it short and plain). We recommend a small, filled box in the neutral colour, which stands apart from the page in light and dark themes alike. The tooltip always renders right after its trigger, which you can see in the DOM contract.
Label and description
A tooltip can do one of two jobs, and the type prop says which, so screen readers hear the right thing:
type="label"shows the name the trigger already has. It's for icon-only buttons, whose visually hidden label names them. Screen readers already read that name, so the tooltip is hidden from them, and they don't hear it twice.type="description", the default, adds a hint to a trigger that has a name of its own. The trigger points to the tooltip witharia-describedby, so screen readers read the hint after the name, as in "Archive, button, Archived projects stay in search".
Here's a row of formatting buttons, each one icon-only, with a tooltip that shows its label. Hover over one, then move along the row, and the next tooltip opens without the wait:
const actions = [
{ label: "Bold", icon: <BoldIcon /> },
{ label: "Italic", icon: <ItalicIcon /> },
{ label: "Underline", icon: <UnderlineIcon /> },
{ label: "Strikethrough", icon: <StrikethroughIcon /> },
];
export function Formatting() {
return actions.map(({ label, icon }) => (
<Tooltip key={label} label={label} type="label">
{(trigger) => <Button {...trigger} label={label} variant="ghost" iconOnly leading={icon} />}
</Tooltip>
));
}
The Button's own label is what names each button, and the tooltip only shows it, so screen readers, voice control and agents all find "Bold" whether the tooltip is open or not. That's also why label is the right type here: with description, screen readers would read "Bold, button, Bold".
A description adds something the name doesn't say, like what an action does to your data:
<Tooltip label="Archived projects stay in search">
{(trigger) => (
<Button {...trigger} variant="outline" leading={<ArchiveIcon />}>
Archive
</Button>
)}
</Tooltip>
We made description the default because it's the one that fails safe. If a trigger has no name of its own, a description still gets read, while a label would leave the trigger silent. Keep in mind that a description is extra: the button has to make sense without it, since touch screen users never see it (see Touch screens).
Placement
The placement prop says which side of its trigger the tooltip opens on, and top is the default. start and end follow the reading direction, so start is on the left in English and on the right in Arabic. When there isn't room on that side, like for a button at the top of the window, the tooltip flips to the other side, and it always stays inside the viewport.
Every tooltip has an arrow by default. It sits on the edge that faces the trigger and points at the trigger's centre, or as close as the rounded corners allow, and it moves to the other edge when the tooltip flips, since an arrow on the far edge would point away from what the tooltip is about. We draw it by default because it shows at a glance which trigger a tooltip belongs to, which is what people get wrong when several triggers sit close together, like the icons of a toolbar (Nielsen Norman Group). Where only one trigger could be meant, you can turn it off by setting arrow to false.
<Tooltip label="Opens above the button">
{(trigger) => <Button {...trigger} variant="outline">Top</Button>}
</Tooltip>
<Tooltip label="Opens below the button" placement="bottom">
{(trigger) => <Button {...trigger} variant="outline">Bottom</Button>}
</Tooltip>
<Tooltip label="Opens before the button" placement="start">
{(trigger) => <Button {...trigger} variant="outline">Start</Button>}
</Tooltip>
<Tooltip label="Opens after the button" placement="end">
{(trigger) => <Button {...trigger} variant="outline">End</Button>}
</Tooltip>
We recommend top for most tooltips, since it's where people look for them and it keeps the hint clear of whatever's below, like the next row of a table. Side placements suit triggers stacked on top of each other, like the icons of a collapsed sidebar, where a tooltip above would cover the one before.
The tooltip opens in the top layer, with popover="manual" and showPopover(), above every other element on the page. That way, no overflow: hidden or z-index around its trigger can clip or cover it, without moving it to the end of the <body>.
CSS anchor positioning then places it beside its trigger (position-area), and flips it when there's no room (position-try-fallbacks). Both work without a library. Browsers that don't have anchor positioning yet get the same placement from a few lines of script, which you'll find in place() in the reference implementation.
States
A tooltip is either closed or open, and it shows which with data-state, so your styles and tests can read it in every browser:
| State | Selector | Recommended look |
|---|---|---|
| Closed | [data-state="closed"] | Hidden, but still in the page, so its trigger's description is always there |
| Open | [data-state="open"] | Shown beside its trigger, fading and growing in from 96% over 0.15 seconds |
| Reduced motion | @media (prefers-reduced-motion: reduce) | Fading in over the same 0.15 seconds, without growing |
The tooltip has no hover or focus styles of its own, since it can't be focused, and the pointer resting on it only keeps it open. Its trigger shows its own states, like the Button's focus ring. You'll find when it opens and closes under Predictable.
Tokens
The tooltip reads two theme tokens, which every component shares:
| Token | Used for |
|---|---|
--color--neutral | The fill |
--color--neutral-contrast | The text on it |
It's the same pair as a solid neutral Button, so the two always look like they belong together. With our reference tokens, from the Tailwind CSS palette, the text comes to 17.7:1 on the light theme's fill and 18.1:1 on the dark theme's. If you change the tokens, make sure to check the same pair. You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.
If you need to tune a tooltip, you can set its own variables from a class:
| Variable | Default | What it sets |
|---|---|---|
--tooltip--background | var(--color--neutral) | The fill |
--tooltip--color | var(--color--neutral-contrast) | The text |
--tooltip--font-size | 0.875rem | The text size, the same as a medium Button's |
--tooltip--padding | 0.375rem 0.625rem | The space around the text |
--tooltip--radius | 0.375rem | The corners |
--tooltip--max-width | 32ch | Where long text wraps |
--tooltip--offset | 0.5rem | The gap between the trigger and the tooltip |
--tooltip--arrow--size | 0.5rem | The arrow's width and height, before it's turned, when it has one |
The Tooltip's styles sit in the components cascade layer, so your class wins without any specificity tricks. Classes you pass to the Tooltip land on the tooltip itself:
import styles from "./Shortcut.module.css";
export function Shortcut() {
return (
<Tooltip label="Opens the command menu, from anywhere in the app" className={styles.wide}>
{(trigger) => <Button {...trigger} variant="ghost">Commands</Button>}
</Tooltip>
);
}
.wide {
--tooltip--max-width: 40ch;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
tooltip/text-only | Must | Component | It holds plain text, with no links, buttons or anything else to focus or click | Open Components | Type check |
tooltip/contrast | Must | Both | Its text has a contrast of 4.5:1 against its fill | WCAG 1.4.3 (AA) | Contrast checker |
tooltip/wraps | Should | Component | Long text wraps at a maximum width set in ch, and is never cut off or scrolled | WCAG 1.4.4, 1.4.10 (AA) | 200% zoom |
tooltip/top-layer | Must | Component | It opens in the top layer, so no overflow or z-index around its trigger clips or covers it | HTML | Visual regression test |
tooltip/flips | Must | Component | It opens beside its trigger, flips to the opposite side when there's no room and stays inside the viewport | Open Components | Visual regression test |
tooltip/points-at-trigger | Must | Component | An arrow, when there is one, sits on the edge facing the trigger and points at its centre, or as close as the rounded corners allow, after a flip too | Open Components | Visual regression test |
tooltip/fallback-position | Should | Component | Browsers without CSS anchor positioning get the same placement from a script | Open Components | Visual regression test |
tooltip/reduced-motion | Must | Component | With prefers-reduced-motion: reduce, it fades in without growing or moving | WCAG 2.3.3 (AAA), Open Components | Emulation |
tooltip/forced-colors | Must | Component | In forced colours mode, a border in the system's text colour marks its edge | Open Components | Emulation |
FAQ
Can a tooltip stay dark in dark mode?
Yes. Ours follows the theme's neutral pair, so it's dark on a light page and light on a dark one, which keeps it standing out from the page in both. Some design systems keep tooltips dark in both schemes, so they always read as something on top of the page, like Styleframe does.
To do the same, swap the pair in dark mode from a class, with light-dark(), so the text keeps its 4.5:1 against the fill (tooltip/contrast). With our reference tokens, the swapped pair comes to 18.1:1, the same as the dark theme's own pair. Keep in mind that a dark fill is close to a dark page's own colour, so the tooltip's edge is harder to see there.
import styles from "./SettingsButton.module.css";
export function SettingsButton() {
return (
<Tooltip label="Settings" type="label" className={styles.alwaysDark}>
{(trigger) => <Button {...trigger} label="Settings" variant="ghost" iconOnly leading={<SettingsIcon />} />}
</Tooltip>
);
}
.alwaysDark {
/* In dark mode, the dark theme's pair the other way round: a dark fill with light text. */
--tooltip--background: light-dark(var(--color--neutral), var(--color--neutral-contrast));
--tooltip--color: light-dark(var(--color--neutral-contrast), var(--color--neutral));
}
arrow to false, where only one trigger could be meant and the offset already makes that clear. It's on by default since it shows at a glance which trigger a tooltip belongs to, which matters most when several triggers sit close together, like the icons of a toolbar. The arrow is a part of its own, data-slot="arrow", sized with --tooltip--arrow--size, the name the Design Tokens page gives it. While it's open, the Tooltip works out which side it's on, after any flip, sets data-side and points the arrow from there.position-area and position-try-fallbacks, is in Chrome and Edge 129, Firefox 147 and Safari 26, according to MDN's browser compatibility data. Chrome had anchor-name from version 125, before position-area, which is why our reference implementation checks for position-area before it leaves the placement to CSS. In a browser without either, the tooltip still opens beside its trigger, placed by its script, just without the top layer.