Components

Tooltip

How to build a tooltip that shows a short hint on hover and keyboard focus, that people can reach, read and dismiss, and that agents can check.

Tooltips show a short hint next to the element it's about, like the name of an icon-only button or what an action will do, when people hover or focus it. They're small, and they're everywhere, which is why they so often get in the way. Think of a tooltip that only opens for a mouse, so keyboard users never see it, one that vanishes as you move the pointer towards it to read it, or one with a link inside that nobody can ever click.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how a tooltip looks and where it opens
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so hover or tab to the examples as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Tooltip with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Tooltip is one of the overlays on our Roadmap, the components that sit on top of the page. The rules it settles for opening in the top layer and closing with Escape are the ones the Popover, the Dialog and the Dropdown Menu will follow too.

<Tooltip label="Settings" type="label">
  {(trigger) => (
    <Button
      {...trigger}
      label="Settings"
      variant="outline"
      iconOnly
      leading={<SettingsIcon />}
    />
  )}
</Tooltip>

At a glance

AspectTooltip
ElementA <span popover="manual">, rendered right after its trigger
Roletooltip with type="description". With type="label", none, since it's hidden with aria-hidden
NameNone of its own. Its text describes the trigger, or shows the name the trigger already has
KeyboardOpens when its trigger gets focus, and Escape closes it, with focus staying where it was
PointerOpens after the pointer rests on its trigger for 500ms, and stays open while the pointer is on it
TouchNever opens, so what it says is always somewhere else too
StatesClosed and open, as data-state
PatternTooltip in the APG
WCAG 2.21.4.3, 1.4.4, 1.4.10, 1.4.13, 2.1.1, 2.3.3 and 4.1.2

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

  1. Trigger — The element the tooltip is about, like a Button. It's yours, it takes focus and it keeps its own name.
  2. Tooltip — A <span> that holds the text, on a filled box with rounded corners, in the top layer while it's open.
  3. Offset — The gap between the trigger and the tooltip, which the pointer can cross without closing it.
  4. 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 with aria-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:

Archived projects stay in search
<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.

Opens above the buttonOpens below the buttonOpens before the buttonOpens after the button
<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:

StateSelectorRecommended 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:

TokenUsed for
--color--neutralThe fill
--color--neutral-contrastThe 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:

VariableDefaultWhat it sets
--tooltip--backgroundvar(--color--neutral)The fill
--tooltip--colorvar(--color--neutral-contrast)The text
--tooltip--font-size0.875remThe text size, the same as a medium Button's
--tooltip--padding0.375rem 0.625remThe space around the text
--tooltip--radius0.375remThe corners
--tooltip--max-width32chWhere long text wraps
--tooltip--offset0.5remThe gap between the trigger and the tooltip
--tooltip--arrow--size0.5remThe 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:

Shortcut.tsx
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>
  );
}
Shortcut.module.css
.wide {
  --tooltip--max-width: 40ch;
}

User Experience (UX)

Now let's look at how a tooltip behaves for every person, input and setting. A good tooltip is accessible, predictable, designed in every state and adaptive.

Accessible

Pick the right type

Screen readers never see a tooltip open. They read the trigger, so what matters is how the tooltip's text reaches it:

TypeThe tooltipThe triggerScreen readers hear
labelaria-hidden="true", with no roleKeeps its own name, like a visually hidden label"Bold, button"
descriptionrole="tooltip", with an idPoints to it with aria-describedby"Archive, button, Archived projects stay in search"

Never point to a tooltip with aria-labelledby. It would replace the trigger's own name with the tooltip's text, and an icon-only Button's name comes from its hidden label, as the Button's button/accessible-name rule asks. Hidden text gets translated along with the page, and stays the name when the tooltip's text changes, like a shortcut added to it. With type="label", the Tooltip links nothing at all, and the name stays where it was.

Open it on focus

Keyboard users can't hover, so the tooltip opens the moment its trigger gets focus, without the delay a pointer gets (WCAG 2.1.1). It closes again when focus moves on. That's why the trigger has to be focusable, like a Button or a link. A tooltip on a <span> or an icon on its own never opens for the keyboard, and screen readers have no element to read its description with.

Let people dismiss it

A tooltip covers part of the page, so people need a way to put it away without moving (WCAG 1.4.13). Escape closes it, wherever focus is, and focus stays on the trigger, so keyboard users don't lose their place. A screen magnifier user who's zoomed in can close a tooltip that hides what they were reading, without moving the pointer away from the trigger and losing their spot.

Let the pointer reach it

People need to be able to move the pointer onto a tooltip, whether to read it at a larger zoom or because their hand isn't steady (WCAG 1.4.13). When the pointer leaves the trigger, the tooltip waits 150ms before it closes, which is long enough to cross the offset. Once the pointer is on the tooltip, it stays open until the pointer leaves both.

Contrast

The text needs a contrast of 4.5:1 against the tooltip's fill (WCAG 1.4.3). It's small text, so there's no room to go lower, and with our reference tokens it comes to about 18:1. The fill doesn't need a contrast of its own against the page, since the text is what people read, but a fill that stands out, like ours, shows where the hint starts and ends.

Predictable

Wait before opening on hover

A tooltip that opens the moment the pointer touches its trigger flashes up every time someone crosses the page. On hover, ours waits for the pointer to rest on the trigger for 500ms, which is what we recommend, and opens only if it's still there. Focus doesn't wait, since moving focus is always on purpose.

Once one tooltip has opened, people are usually scanning, like along a toolbar. So moving the pointer straight on to the next trigger opens its tooltip at once, and so does any trigger within 300ms of a tooltip closing. Radix, for example, waits 700ms by default, and skips the delay for 300ms.

Keep it open while it's needed

The tooltip stays open for as long as its trigger has hover or focus, and never closes on a timer (WCAG 1.4.13). Some people read slowly, and some need to zoom in first. It closes when both the pointer and focus have left, or with Escape.

One at a time

Opening a tooltip closes any other one that's open. Two tooltips at once would cover twice as much of the page, and it'd be unclear which one goes with what. That includes a tooltip opened by focus and another by the pointer: the newest one wins.

Keep Escape for the top layer

When a tooltip is open inside something else that closes with Escape, like a Dialog, Escape closes the tooltip and goes no further. Otherwise one key press would close both, and people would lose the dialog along with their work in it.

Our Tooltip listens for Escape only while it's open, in the capture phase on the document, so it comes before a dialog's own handler, and stops it there. It cancels its default action too, so a native <dialog> doesn't close either. With no tooltip open, Escape reaches the dialog as usual. The Popover, Dialog and Dropdown Menu on our Roadmap will follow the same rule, so Escape always closes the layer on top first.

Keep it short and plain

A tooltip holds a few words of plain text, like a name or a short hint. It's never the place for instructions, an error message or anything people need to act on.

Nothing in it can be focused or clicked. A link or a button inside a tooltip can't be reached, since focus would have to leave the trigger, which closes the tooltip. If you need either, use a Popover, which is on our Roadmap.

Don't add a title attribute to the trigger as well. Browsers draw a tooltip of their own for it, which can't be styled, doesn't open for the keyboard and would show up next to yours.

Every state

Closed

The tooltip is in the page, hidden, from the moment it mounts. A description is read with its trigger whether the tooltip is open or not, so screen reader users hear it as soon as they reach the trigger, without anything having to open.

Open

The tooltip is beside its trigger, in the top layer. It stays there while the pointer is on the trigger or on the tooltip, or while the trigger has focus, and it doesn't move once it's open, so people can read it. If the page scrolls, it follows its trigger.

Disabled triggers

A native disabled button leaves the tab order, so keyboard users can never open its tooltip. When a tooltip says why a button is disabled, give the Button focusableWhenDisabled, which sets aria-disabled instead, as the Button's Disabled state recommends. The button stays in the tab order, its tooltip opens on focus and hover, and the Button still blocks the click.

Add a title to publish

A title is required

<Tooltip label="Add a title to publish">
  {(trigger) => (
    <Button {...trigger} color="primary" disabled focusableWhenDisabled>
      Publish
    </Button>
  )}
</Tooltip>
<p>A title is required</p>

The reason is on the page too, since a tap never opens a tooltip. A disabled button that only explains itself in a tooltip leaves touch screen users guessing.

Adaptive

Touch screens

Touch screens have no hover, and a tap on a button is meant to press it, so a tap never opens a tooltip. That includes the focus a tap moves to the trigger, which would otherwise open a tooltip that nothing closes until the next tap somewhere else. The reference implementation tells a tap apart by its pointer type, touch, rather than by the screen's size, so a laptop with a touch screen still shows tooltips to its mouse.

That's why a tooltip is never the only place something lives. A label repeats the trigger's own name, and a description adds a hint people can do without, or one that's on the page too, like the reason in the example under Disabled triggers. For a hint people need on every device, use visible text, or a toggletip, a button that shows the hint when it's pressed, which belongs to the Popover on our Roadmap.

More contrast

When someone asks for more contrast (prefers-contrast: more), nothing needs to change. The text already has well over 7:1 against the fill with our reference tokens.

Forced colours

Windows contrast themes replace every colour with the user's own, which drops the tooltip's fill. Ours has a transparent border, which forced colours mode draws in the system's text colour, so its edge stays visible, and it switches to Canvas and CanvasText, the page's own colours, so it reads like the rest of the page. The arrow has the same border on its outer edges, so it stays visible too.

Colour scheme

The fill and its text come from --color--neutral and --color--neutral-contrast, which our reference tokens set with light-dark(). So the tooltip is dark on a light page and light on a dark one, and stands out on both.

Text size and zoom

Sizes are in rem, so the tooltip grows at 200% text size along with everything else (WCAG 1.4.4). Its maximum width is in ch, about 32 characters, so long text wraps onto more lines as it grows rather than running off the screen (WCAG 1.4.10).

Motion

The tooltip fades in and grows from 96% over 0.15 seconds. When someone prefers reduced motion (prefers-reduced-motion: reduce), it only fades (WCAG 2.3.3). It still eases in rather than popping up, since a fade changes nothing's position or size.

Languages and direction

The tooltip's text is yours, so remember to translate it along with the rest of the page. With type="label", translating the trigger's label is enough, since the tooltip shows the same text. start and end follow the reading direction, so a tooltip at the end of a button opens on its right in English and on its left in Arabic, without any change to your code.

Developer Experience (DX)

Next, let's look at how developers use the Tooltip. You give it its text and wrap it around the trigger, which takes a few props from it, and it handles when to open, where and for whom.

import { Button } from "@/components/Button";
import { Tooltip } from "@/components/Tooltip";

export function SettingsButton() {
  return (
    <Tooltip label="Settings" type="label">
      {(trigger) => <Button {...trigger} label="Settings" variant="ghost" iconOnly leading={<SettingsIcon />} />}
    </Tooltip>
  );
}

Consistent

The Tooltip uses the same names as every other component in Open Components, so there's little new to learn:

NameKindWhat it means
labelPropThe text content, when it's plain text, as on the Button
typePropWhether its text is the trigger's label or a description
delayPropHow long the pointer rests on the trigger before it opens, in ms
placementPropThe side of the trigger it opens on: top, bottom, start or end
arrowPropDraws an arrow that points at the trigger

We recommend type="description", delay={500}, placement="top" and an arrow as the defaults. The Tooltip also leaves a few things out, on purpose:

  • There's no slot for its content, only label. A slot would invite links, buttons and icons, which nobody could reach (see Keep it short and plain).
  • There's no open prop. When a tooltip opens is what makes it accessible, so the Tooltip keeps it to itself, and something you'd want to show on your own terms is a Popover.
  • There's no color or size. A tooltip has no intent of its own, and its text is always small and short.
  • There's no disabled either. To have no tooltip, render the trigger without one.

Type-safe

Props and the trigger's bindings are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like placement="left", which isn't one of the Tooltip's placements, or a tooltip without a label.

PropTypeDefaultDescription
labelstringThe text it shows. Required, and plain text only
type"label" | "description""description"Whether it shows the trigger's own name, or describes it
delaynumber500How long the pointer rests on the trigger before it opens, in milliseconds
placement"top" | "bottom" | "start" | "end""top"The side of the trigger it opens on, before it flips
arrowbooleantrueDraws an arrow that points at the trigger, on whichever side it opens
SlotDescription
defaultThe trigger, which binds trigger on its focusable element

The trigger's bindings are typed as TooltipTrigger: aria-describedby for a description, the anchor's name in style, and the pointer and focus listeners. Bind them all, on the element that takes focus. The Tooltip fires no events of its own. We also export TooltipProps, which comes in handy for wrappers:

import { Tooltip, type TooltipProps } from "@/components/Tooltip";

export function Hint(props: TooltipProps) {
  return <Tooltip delay={300} {...props} />;
}

Composable

The Tooltip adds no wrapper around its trigger. It renders the trigger you give it, then the tooltip right after it, so your layout, your flex gaps and your trigger's own attributes stay as they are. How the trigger takes its props is the one thing that differs from framework to framework:

FrameworkThe trigger
ReactA function child, which gets trigger to spread on the element
VueThe default slot, with trigger to bind with v-bind
SvelteA children snippet, which gets trigger to spread
AngularThe element the appTooltip directive is on, which adds the tooltip after it, with tooltipType, tooltipDelay, tooltipPlacement, tooltipArrow and tooltipClass as its other inputs
SolidA function child, like React's
AstroThe default slot, which a <script> wires up in the browser, as the Button's Astro examples do
VanillaThe element right before the tooltip, which a script wires up

Bind the props on the element that takes focus, which is usually the trigger's root. Components like the Button pass them straight through, attributes and listeners alike, so binding them on the Button is enough. If your trigger's root isn't the focusable element, like a card with a button inside it, bind them on the button instead.

With other components

  • An icon-only Button keeps its visually hidden label, and its tooltip shows that same label, with type="label".
  • A Button with focusableWhenDisabled stays focusable while it's disabled, so its tooltip can still say why (see Disabled triggers).
  • An Input is described by its hint, which stays on the page rather than in a tooltip, since people need it while they type.
  • A Dialog or Popover around a Tooltip keeps its own Escape handling. The Tooltip only takes Escape while it's open, and stops it there.
  • A toggletip, which shows a hint when a button is pressed, and anything with links or buttons inside, will be a Popover, on our Roadmap.

Controllable

The Tooltip keeps its open state to itself, since when it opens is what makes it work for every input. Everything else is within reach:

WhatHow you control it
Textlabel
Typetype
Delaydelay, for the pointer. Focus always opens it at once
Placementplacement, before it flips
Arrowarrow, on by default, and its size with --tooltip--arrow--size
ShownRender the Tooltip around the trigger, or render the trigger alone
LookIts variables, from a class
OpenRead data-state, which is open or closed

Agentic Experience (AX)

Finally, let's look at how agents read, build and check a tooltip. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names and descriptions. A tooltip doesn't have to be open for them to read it. This is how they see the formatting buttons and the Archive button on this page:

- button "Bold"
- button "Italic"
- button "Underline"
- button "Strikethrough"
- button "Archive"

With type="label", the tooltip isn't in the tree at all, and the button's own label names it. With type="description", the tooltip's text is the button's accessible description, so an agent can find the button by its name and check its hint without hovering anything:

await expect(page.getByRole("button", { name: "Archive" })).toHaveAccessibleDescription("Archived projects stay in search");

Described

Here's the whole contract in one block, for agents to read before they build or review a tooltip:

component: Tooltip
summary: Shows a short text hint next to its trigger, on hover and on keyboard focus. Text only, and never the only place its text is.
standard: https://opencomponents.dev/docs/components/tooltip
element: span # popover="manual", right after its trigger, and in the top layer while it's open
role: tooltip # with type="description". With type="label", it's aria-hidden, with no role
props:
  label: { type: string } # required, and plain text only
  type: { type: [label, description], default: description }
  delay: { type: number, default: 500 } # in milliseconds, for the pointer only
  placement: { type: [top, bottom, start, end], default: top }
  arrow: { type: boolean, default: true }
slots:
  default: The trigger, which binds the props it's given, like aria-describedby and the listeners, on its focusable element.
states:
  closed: "[data-state=closed]"
  open: "[data-state=open]"
keyboard:
  Tab: Focus on the trigger opens it at once, and focus leaving closes it.
  Escape: Closes it while it's open, keeping focus on the trigger, and stops there.
parts: [arrow] # data-slot, after the text, unless arrow is false
tokens:
  theme: ["--color--neutral", "--color--neutral-contrast"]
  variables: [--tooltip--background, --tooltip--color, --tooltip--font-size, --tooltip--padding, --tooltip--radius, --tooltip--max-width, --tooltip--offset, --tooltip--arrow--size]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/tooltip#checklist

It's also published on its own at /raw/docs/components/tooltip.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like tooltip/contain-escape, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's what the Archive button under Label and description renders once it's in the browser, before it's opened, without its icon:

<button
  class="button"
  type="button"
  data-variant="outline"
  data-color="neutral"
  data-size="md"
  aria-describedby="v-0"
  style="anchor-name: --tooltip-v-0"
>
  <span data-slot="label">Archive</span>
</button>
<span
  id="v-0"
  class="tooltip"
  role="tooltip"
  popover="manual"
  data-state="closed"
  data-placement="top"
  style="position-anchor: --tooltip-v-0"
>Archived projects stay in search<span data-slot="arrow" aria-hidden="true"></span></span>
  • The tooltip always comes right after its trigger, and is always in the page, open or not. Opening it changes data-state, and never adds or removes an element.
  • data-state and data-placement are always there, while data-delay only appears when the delay isn't 500ms, the default. It's what the Vanilla tooltip.js reads. Unless arrow is false, the tooltip holds a <span data-slot="arrow" aria-hidden="true"> after its text, and once it's open, data-side says which side of the trigger it's on, so the arrow can point back. It's a physical side, top, bottom, left or right, so start comes out as left or right by direction, and it stays set after the tooltip closes. With type="label", role gives way to aria-hidden="true", and the trigger has no aria-describedby.
  • The id is the one you pass, or one from your framework's ID helper. The anchor's name always comes from the generated one, since an id can hold characters a CSS name can't, like 1:2, so every tooltip on the page anchors to its own trigger. Both are set once the Tooltip mounts, and the server renders the trigger and the tooltip without them. Generated IDs can differ between the server and the client, like in content that renders asynchronously, and frameworks don't always fix a mismatched attribute when the page hydrates, which would leave the trigger pointing at an id that's gone.
  • In browsers without CSS anchor positioning, an open tooltip also gets top and left, from the script that places it.

Verifiable

Every rule in the checklist can be checked, and about two thirds of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Tooltip.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them. They use fake timers for the delays, and read the open state from data-state, since jsdom doesn't have the Popover API.

In the browser

Placement, flipping and the top layer only exist in a real browser, so check them there. You can hover and focus the trigger the way people would, emulate touch and run axe over the lot:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the Archive button's tooltip opens for the keyboard and the pointer, and Escape closes it", async ({ page }) => {
  await page.goto("/projects");
  const archive = page.getByRole("button", { name: "Archive" });
  const tooltip = page.getByRole("tooltip", { name: "Archived projects stay in search" });

  await expect(archive).toHaveAccessibleDescription("Archived projects stay in search");

  await archive.focus();
  await expect(tooltip).toBeVisible();
  await page.keyboard.press("Escape");
  await expect(tooltip).toBeHidden();
  await expect(archive).toBeFocused();

  await archive.hover();
  await expect(tooltip).toBeVisible();
  await tooltip.hover();
  await expect(tooltip).toBeVisible();

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);
});

Some things still need a human. Look at tooltips at the edges of the window, at 200% zoom and with forced colours turned on, which you can emulate in Chrome DevTools under Rendering, and check that they flip, wrap and keep their edge. Then try the page with a screen reader, and check that labels aren't read twice and descriptions are read with their triggers.

Prompts

You can point your agent straight at this page. To build a tooltip, you could use:

Build a Tooltip component for our React design system that meets the Open Components
Tooltip standard: https://opencomponents.dev/raw/docs/components/tooltip.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Tooltip against the Open Components Tooltip contract:
https://opencomponents.dev/raw/docs/components/tooltip.yaml

For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.

And to review how a screen uses its tooltips:

Review how our editor toolbar uses its tooltips against the Open Components
Tooltip contract: https://opencomponents.dev/raw/docs/components/tooltip.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A tooltip meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Tooltip itself, so they're the ones to check when you build or review one. Usage rules are met by the screens that use it, like never hiding something people need in a tooltip, so they're the ones to check when you review a screen. Both rules need the two to work together, like contrast: the Tooltip uses the theme's neutral pair, as long as your theme's pair passes.

The basis says where a rule comes from. A third of the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, most often 1.4.13, and a few come from HTML or WAI-ARIA. The rest are Open Components rules, which are our own, like opening one tooltip at a time. Where a rule goes further than WCAG asks at level AA, its basis says so: the reduced-motion rule comes from level AAA, and a rule that asks for more than its criterion says beyond, like keeping a disabled trigger focusable.

UI rules

RuleLevelScopeRequirementBasisCheck
tooltip/text-onlyMustComponentIt holds plain text, with no links, buttons or anything else to focus or clickOpen ComponentsType check
tooltip/contrastMustBothIts text has a contrast of 4.5:1 against its fillWCAG 1.4.3 (AA)Contrast checker
tooltip/wrapsShouldComponentLong text wraps at a maximum width set in ch, and is never cut off or scrolledWCAG 1.4.4, 1.4.10 (AA)200% zoom
tooltip/top-layerMustComponentIt opens in the top layer, so no overflow or z-index around its trigger clips or covers itHTMLVisual regression test
tooltip/flipsMustComponentIt opens beside its trigger, flips to the opposite side when there's no room and stays inside the viewportOpen ComponentsVisual regression test
tooltip/points-at-triggerMustComponentAn 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 tooOpen ComponentsVisual regression test
tooltip/fallback-positionShouldComponentBrowsers without CSS anchor positioning get the same placement from a scriptOpen ComponentsVisual regression test
tooltip/reduced-motionMustComponentWith prefers-reduced-motion: reduce, it fades in without growing or movingWCAG 2.3.3 (AAA), Open ComponentsEmulation
tooltip/forced-colorsMustComponentIn forced colours mode, a border in the system's text colour marks its edgeOpen ComponentsEmulation

UX rules

RuleLevelScopeRequirementBasisCheck
tooltip/focus-opensMustComponentKeyboard focus on its trigger opens it at onceWCAG 2.1.1 (A)Unit test, keyboard
tooltip/hover-delayShouldComponentThe pointer rests on its trigger for a delay, about 500ms, before it opensOpen ComponentsUnit test
tooltip/skip-delayShouldComponentMoving the pointer straight on to the next trigger opens that tooltip without waitingOpen ComponentsUnit test
tooltip/hoverableMustComponentThe pointer can cross from the trigger onto the tooltip, within a short grace period, without closing itWCAG 1.4.13 (AA)Unit test
tooltip/persistentMustComponentIt stays open while its trigger has hover or focus, and never hides on a timerWCAG 1.4.13 (AA)Unit test
tooltip/dismiss-with-escapeMustComponentEscape dismisses it without moving focus or the pointerWCAG 1.4.13 (AA)Unit test, keyboard
tooltip/contain-escapeMustComponentA key press that closes it stops there, so a dialog or popover around it stays open, while one with no tooltip open reaches them as usualOpen ComponentsUnit test
tooltip/one-at-a-timeMustComponentOpening a tooltip closes any other one that's openOpen ComponentsUnit test
tooltip/no-touchMustComponentA tap never opens it, even when the tap moves focus to its triggerOpen ComponentsUnit test
tooltip/not-the-only-placeMustUsageNothing people need is only in a tooltip, since touch screen users can't open oneOpen ComponentsReview
tooltip/focusable-triggerMustUsageIts trigger is focusable, and a disabled one uses aria-disabled rather than disabledOpen Components, beyond WCAG 2.1.1 (A)Keyboard
tooltip/shortShouldUsageIts text is a few words long, a hint rather than instructionsOpen ComponentsReview
tooltip/no-titleShouldUsageIts trigger has no title attribute, which would show a second, browser-drawn tooltipOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
tooltip/trigger-propsMustComponentThe trigger is the element you render, which binds the props the Tooltip hands it, with no wrapper elementOpen ComponentsUnit test
tooltip/label-propShouldComponentIts text comes from label, the name every component uses for plain textOpen ComponentsType check
tooltip/attributes-on-tooltipShouldComponentClasses and attributes you pass land on the tooltip, so a class can set its variablesOpen ComponentsUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
tooltip/describesMustComponentWith type="description", it's role="tooltip", and its trigger points to its id with aria-describedbyWAI-ARIAUnit test, screen reader
tooltip/shows-labelMustComponentWith type="label", it's aria-hidden, nothing points to it, and the trigger keeps its own nameWCAG 4.1.2 (A), Open ComponentsUnit test
tooltip/never-labelledbyMustBothNo trigger is named by its tooltip through aria-labelledbyOpen ComponentsUnit test, review
tooltip/named-triggerMustUsageIts trigger has an accessible name of its own, like an icon-only Button's hidden labelWCAG 4.1.2 (A)ARIA snapshot
tooltip/in-the-domMustComponentIt stays in the DOM while it's closed, so its trigger's description is always thereWAI-ARIAUnit test
tooltip/data-stateShouldComponentIts open state shows as data-state, open or closed, so tests and agents can read itOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves: opening on focus at once and on hover after a delay, staying open while the pointer is on it, closing with Escape without letting it go further, and never opening on a tap.

It's one component, with no dependencies. The tests are in Tooltip.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script lang="ts">
// Shared by every tooltip on the page, so only one is open at a time, and once one
// has opened, the next opens at once, as the pointer moves along a toolbar.
let closeOpen: (() => void) | undefined;
let warm = false;
let cooling: ReturnType<typeof setTimeout> | undefined;

/** How long the next tooltip skips the delay for, after one closes. */
const SKIP_DELAY = 300;
/** How long a tooltip waits after the pointer leaves, so the pointer can move onto it. */
const GRACE = 150;
</script>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, useTemplateRef } from "vue";

export interface TooltipProps {
  /** The text it shows. Plain text only, so there's never anything in it to focus or click. */
  label: string;
  /**
   * "description" adds the text to the trigger's description. "label" shows the name the
   * trigger already has, like an icon-only button's hidden label, and stays hidden from
   * screen readers, so they don't read it twice.
   */
  type?: "label" | "description";
  /** How long the pointer rests on the trigger before it opens, in milliseconds. Focus opens it at once. */
  delay?: number;
  /** The side of the trigger it opens on. It flips to the other side when there's no room. */
  placement?: "top" | "bottom" | "start" | "end";
  /**
   * Draws an arrow that points at the trigger, on whichever side it opens, so it's clear which
   * trigger it belongs to when several sit close together. Turn it off with `false`.
   */
  arrow?: boolean;
}

/** What the trigger binds on its focusable element. */
export interface TooltipTrigger {
  "aria-describedby"?: string;
  style: { "anchor-name"?: string };
  onPointerenter: (event: PointerEvent) => void;
  onPointerleave: (event: PointerEvent) => void;
  onPointerdown: (event: PointerEvent) => void;
  onFocus: (event: FocusEvent) => void;
  onBlur: () => void;
}

// The root is a fragment, the trigger and then the tooltip, so attributes go on the
// tooltip, where a class can set its variables.
defineOptions({ inheritAttrs: false });

const { label, type = "description", delay = 500, placement = "top", arrow = true } = defineProps<TooltipProps>();

defineSlots<{
  /** The trigger, which binds `trigger` on its focusable element. */
  default: (props: { trigger: TooltipTrigger }) => unknown;
}>();

// The ID, and the anchor's name, are set once it mounts. The ID is the one you pass, or a
// generated one. Generated IDs can differ between the server and the client, like in content
// that renders asynchronously, and hydration doesn't fix the trigger's attributes, which
// would then point at nothing.
const attrs = useAttrs();
const uid = useId();
const id = ref<string>();
// The anchor's name always comes from the generated ID, since an id you pass can hold
// characters a CSS name can't, like "1:2", and the browser would drop the anchor.
const anchorName = computed(() => id.value && `--tooltip-${uid}`);
onMounted(() => (id.value = (attrs.id as string | undefined) ?? uid));
const state = ref<"open" | "closed">("closed");
// The side it opened on, after any flip, which the arrow points from. Only aim() sets it,
// so a tooltip without an arrow never has one.
const side = ref<"top" | "bottom" | "left" | "right">();
const tooltip = useTemplateRef<HTMLElement>("tooltip");
const arrowPart = useTemplateRef<HTMLElement>("arrow-part");

let anchor: HTMLElement | undefined;
let hovered = false;
let focused = false;
let touched = false;
// A tap doesn't always focus the trigger, like in Safari on iOS, or when it turns into a
// scroll, so the next key press, like the Tab that moves focus, forgets it.
const untouch = () => (touched = false);
let opening: ReturnType<typeof setTimeout> | undefined;
let closing: ReturnType<typeof setTimeout> | undefined;
let frame: number | undefined;

// CSS anchor positioning places it, where the browser has it. Elsewhere, place() does.
// It checks position-area rather than anchor-name, which Chrome had four versions earlier.
const anchored = () => globalThis.CSS?.supports?.("position-area: top") ?? false;

function show() {
  clearTimeout(opening);
  clearTimeout(closing);
  if (state.value === "open") return;
  closeOpen?.();
  closeOpen = hide;
  state.value = "open";
  // The top layer, above everything else, so no overflow or z-index around the trigger can
  // clip or cover it. Browsers without the Popover API show it where it is.
  tooltip.value?.showPopover?.();
  // In the capture phase, so it runs before a dialog's own Escape handler.
  document.addEventListener("keydown", onKeydown, true);
  // Once it's shown, so it has a size to measure.
  nextTick(update);
  if (!anchored()) {
    addEventListener("scroll", update, { capture: true, passive: true });
    addEventListener("resize", update);
  }
  // The arrow follows it on every frame while it's open: the browser moves an anchored tooltip
  // after a scroll handler runs, and a flip or a layout shift can move it with no scroll at all.
  if (arrow) nextTick(follow);
}

// It stops once the tooltip has closed, since show() queues it for the next tick, and a tooltip
// that opens and closes in the same task, like with focus() and then blur(), is closed by then.
function follow() {
  if (state.value !== "open") return;
  aim();
  frame = requestAnimationFrame(follow);
}

function hide() {
  clearTimeout(opening);
  clearTimeout(closing);
  if (state.value === "closed") return;
  state.value = "closed";
  tooltip.value?.hidePopover?.();
  document.removeEventListener("keydown", onKeydown, true);
  removeEventListener("scroll", update, { capture: true });
  removeEventListener("resize", update);
  if (frame !== undefined) cancelAnimationFrame(frame);
  frame = undefined;
  if (closeOpen === hide) {
    closeOpen = undefined;
    warm = true;
    clearTimeout(cooling);
    cooling = setTimeout(() => (warm = false), SKIP_DELAY);
  }
}

function closeSoon() {
  clearTimeout(closing);
  closing = setTimeout(() => {
    if (!hovered && !focused) hide();
  }, GRACE);
}

function onKeydown(event: KeyboardEvent) {
  if (event.key !== "Escape") return;
  // Escape closes the tooltip and goes no further, so a dialog or popover around it stays
  // open. It's only listened for while a tooltip is open, so otherwise it's theirs.
  event.stopPropagation();
  event.preventDefault();
  hide();
}

const trigger = computed<TooltipTrigger>(() => ({
  // A label is the trigger's own name, which it keeps, so nothing points to the tooltip.
  // Never aria-labelledby, which would replace that name.
  "aria-describedby": type === "description" ? id.value : undefined,
  style: anchorName.value ? { "anchor-name": anchorName.value } : {},
  onPointerenter(event) {
    // A tap never opens it: touch screens have no hover, and nothing would close it.
    if (event.pointerType === "touch") return;
    anchor = event.currentTarget as HTMLElement;
    hovered = true;
    clearTimeout(closing);
    if (state.value === "open") return;
    if (closeOpen || warm) return show();
    clearTimeout(opening);
    opening = setTimeout(show, delay);
  },
  onPointerleave(event) {
    if (event.pointerType === "touch") return;
    hovered = false;
    clearTimeout(opening);
    closeSoon();
  },
  onPointerdown(event) {
    touched = event.pointerType === "touch";
    if (touched) document.addEventListener("keydown", untouch, { capture: true, once: true });
  },
  onFocus(event) {
    focused = true;
    anchor = event.currentTarget as HTMLElement;
    // A tap focuses the trigger too, which shouldn't open it any more than the tap does.
    if (!touched) show();
  },
  onBlur() {
    focused = false;
    touched = false;
    if (!hovered) hide();
  },
}));

function update() {
  if (!anchored()) place();
  if (arrow) aim();
}

// Without anchor positioning: the chosen side of the trigger, or the other side when it
// doesn't fit, centred on the trigger and kept inside the viewport.
function place() {
  const el = tooltip.value;
  if (!el || !anchor) return;
  // Its margin is the gap, which `left` and `top` place it outside of.
  const gap = parseFloat(getComputedStyle(el).marginTop) || 0;
  const a = anchor.getBoundingClientRect();
  // Its exact size, without the scale it grows in from, which getBoundingClientRect() would include.
  const width = parseFloat(getComputedStyle(el).width);
  const height = parseFloat(getComputedStyle(el).height);
  const { clientWidth, clientHeight } = document.documentElement;
  const rtl = getComputedStyle(anchor).direction === "rtl";
  const sides = { top: "top", bottom: "bottom", start: rtl ? "right" : "left", end: rtl ? "left" : "right" } as const;
  const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" } as const;
  const fits = {
    top: a.top - gap - height >= 0,
    bottom: a.bottom + gap + height <= clientHeight,
    left: a.left - gap - width >= 0,
    right: a.right + gap + width <= clientWidth,
  };
  let side: keyof typeof opposite = sides[placement];
  if (!fits[side] && fits[opposite[side]]) side = opposite[side];
  const across = side === "top" || side === "bottom";
  const x = across ? a.left + (a.width - width) / 2 : side === "left" ? a.left - gap - width : a.right + gap;
  const y = across ? (side === "top" ? a.top - gap - height : a.bottom + gap) : a.top + (a.height - height) / 2;
  el.style.left = `${Math.max(0, Math.min(x, clientWidth - width)) - gap}px`;
  el.style.top = `${Math.max(0, Math.min(y, clientHeight - height)) - gap}px`;
}

// With an arrow: the side it opened on, from where its centre is, which its scale doesn't move,
// and the arrow across from the trigger's centre, or as close as the rounded corners allow.
function aim() {
  const el = tooltip.value;
  const part = arrowPart.value;
  if (!el || !part || !anchor) return;
  const a = anchor.getBoundingClientRect();
  const t = el.getBoundingClientRect();
  const x = t.left + t.width / 2;
  const y = t.top + t.height / 2;
  side.value = y < a.top ? "top" : y > a.bottom ? "bottom" : x < a.left ? "left" : "right";
  const across = side.value === "top" || side.value === "bottom";
  const box = getComputedStyle(el);
  const length = parseFloat(across ? box.width : box.height);
  const centre = across ? a.left + a.width / 2 - (x - length / 2) : a.top + a.height / 2 - (y - length / 2);
  // Its centre stays a corner's radius and half its own diagonal from each end, so none of it
  // overlaps the curve, measured, like the centre, from the outside of the border.
  const clear = parseFloat(box.borderTopLeftRadius) + parseFloat(getComputedStyle(part).width) / Math.SQRT2;
  const at = clear * 2 < length ? Math.max(clear, Math.min(centre, length - clear)) : length / 2;
  // Its left and top start inside the border.
  part.style.left = across ? `${at - el.clientLeft}px` : "";
  part.style.top = across ? "" : `${at - el.clientTop}px`;
}

// The pointer can move from the trigger onto the tooltip, and the tooltip stays open.
function onTooltipEnter() {
  hovered = true;
  clearTimeout(closing);
}
function onTooltipLeave() {
  hovered = false;
  closeSoon();
}

onBeforeUnmount(() => {
  hide();
  document.removeEventListener("keydown", untouch, { capture: true });
});
</script>

<template>
  <slot :trigger="trigger" />
  <!-- A span, so it's valid wherever its trigger is, even in a line of text. It stays in the
       page while it's closed, so the trigger's description is there before it opens. -->
  <span
    ref="tooltip"
    v-bind="$attrs"
    :id="id"
    class="tooltip"
    :role="type === 'description' ? 'tooltip' : undefined"
    :aria-hidden="type === 'label' ? 'true' : undefined"
    popover="manual"
    :data-state="state"
    :data-placement="placement"
    :data-delay="delay === 500 ? undefined : delay"
    :data-side="side"
    :style="anchorName && { 'position-anchor': anchorName }"
    @pointerenter="onTooltipEnter"
    @pointerleave="onTooltipLeave"
  >{{ label }}<span v-if="arrow" ref="arrow-part" data-slot="arrow" aria-hidden="true" /></span>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The tooltip's styles go in `components`,
   so a class of yours that sets its variables wins without any specificity tricks. */
@layer theme, base, components, utilities;

@layer components {
  .tooltip {
    /* The neutral fill and the text on it, which have 4.5:1 or more in both colour schemes. */
    --tooltip--background: var(--color--neutral);
    --tooltip--color: var(--color--neutral-contrast);
    /* The Button's text size, so the hint doesn't read smaller than the control it explains. */
    --tooltip--font-size: 0.875rem;
    --tooltip--padding: 0.375rem 0.625rem;
    --tooltip--radius: 0.375rem;
    /* In ch, so long text wraps at about 32 characters, however big the text gets. */
    --tooltip--max-width: 32ch;
    /* The gap between the trigger and the tooltip. */
    --tooltip--offset: 0.5rem;
    /* The arrow's width and height, before it's turned, when it has one. */
    --tooltip--arrow--size: 0.5rem;

    /* Beside the trigger, centred on it, and on the other side when there's no room. */
    position: fixed;
    position-area: top;
    position-try-fallbacks: flip-block, flip-inline;
    inset: auto;
    margin: var(--tooltip--offset);
    box-sizing: border-box;
    width: max-content;
    max-inline-size: var(--tooltip--max-width);
    padding: var(--tooltip--padding);
    /* Transparent, so it only shows in forced colors mode, which keeps borders. */
    border: 1px solid transparent;
    border-radius: var(--tooltip--radius);
    overflow: visible;
    background-color: var(--tooltip--background);
    color: var(--tooltip--color);
    font-family: inherit;
    font-size: var(--tooltip--font-size);
    font-weight: 500;
    line-height: 1.4;
    overflow-wrap: break-word;
    transition:
      opacity 0.15s ease-out,
      scale 0.15s ease-out;
  }
  .tooltip[data-placement="bottom"] {
    position-area: bottom;
  }
  /* In the tooltip's own direction, which is its trigger's, rather than the page's. */
  .tooltip[data-placement="start"] {
    position-area: self-inline-start;
  }
  .tooltip[data-placement="end"] {
    position-area: self-inline-end;
  }

  /* The arrow: a square turned 45deg, half of it past the edge that faces the trigger, in the
     tooltip's fill and border, so forced colors mode draws its edge too. The edges inside the
     tooltip have no border, and it waits for the side it opened on. */
  .tooltip > [data-slot="arrow"] {
    position: absolute;
    box-sizing: border-box;
    width: var(--tooltip--arrow--size);
    height: var(--tooltip--arrow--size);
    border: inherit;
    background: inherit;
    translate: -50% -50%;
    rotate: 45deg;
  }
  .tooltip:not([data-side]) > [data-slot="arrow"] {
    display: none;
  }
  .tooltip[data-side="top"] > [data-slot="arrow"] {
    top: 100%;
    border-top-width: 0;
    border-left-width: 0;
  }
  .tooltip[data-side="bottom"] > [data-slot="arrow"] {
    top: 0;
    border-right-width: 0;
    border-bottom-width: 0;
  }
  .tooltip[data-side="left"] > [data-slot="arrow"] {
    left: 100%;
    border-bottom-width: 0;
    border-left-width: 0;
  }
  .tooltip[data-side="right"] > [data-slot="arrow"] {
    left: 0;
    border-top-width: 0;
    border-right-width: 0;
  }

  /* Hidden while it's closed, in browsers without the Popover API too. */
  .tooltip:not([data-state="open"]) {
    display: none;
  }
  /* It fades and grows in as it opens. */
  @starting-style {
    .tooltip[data-state="open"] {
      opacity: 0;
      scale: 0.96;
    }
  }

  /* With reduced motion, it only fades in, without growing. */
  @media (prefers-reduced-motion: reduce) {
    .tooltip {
      transition: opacity 0.15s ease-out;
    }
    @starting-style {
      .tooltip[data-state="open"] {
        scale: 1;
      }
    }
  }

  /* Forced colors mode replaces the fill with the page's own colours, so the border marks its edge. */
  @media (forced-colors: active) {
    .tooltip {
      border-color: CanvasText;
      background-color: Canvas;
      color: CanvasText;
    }
  }
}
</style>

FAQ

Tooltip or something else?

A tooltip is one of a few ways to show a hint, and each one suits a different hint:

When the hint…UseBecause
Names an icon-only buttonA Tooltip, with type="label"It shows the name the button already has, on hover and focus
Adds a few words people can do withoutA Tooltip, with type="description"Screen readers read it with the trigger, and it stays out of the way
Is something people need, like a field's formatVisible text, like an Input's hintIt's there on every device, and while people type
Needs a press to show, or holds a link or a buttonA Popover, on our RoadmapIt opens on purpose, takes focus and closes when people are done

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.

SettingsButton.tsx
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>
  );
}
SettingsButton.module.css
.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));
}

Sources

Copyright © 2026