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-stateanddata-placementare always there, whiledata-delayonly appears when the delay isn't 500ms, the default. It's what the Vanillatooltip.jsreads. Unlessarrowisfalse, the tooltip holds a<span data-slot="arrow" aria-hidden="true">after its text, and once it's open,data-sidesays which side of the trigger it's on, so the arrow can point back. It's a physical side,top,bottom,leftorright, sostartcomes out asleftorrightby direction, and it stays set after the tooltip closes. Withtype="label",rolegives way toaria-hidden="true", and the trigger has noaria-describedby.- The
idis the one you pass, or one from your framework's ID helper. The anchor's name always comes from the generated one, since anidcan hold characters a CSS name can't, like1: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 anidthat's gone. - In browsers without CSS anchor positioning, an open tooltip also gets
topandleft, 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.
AX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
tooltip/describes | Must | Component | With type="description", it's role="tooltip", and its trigger points to its id with aria-describedby | WAI-ARIA | Unit test, screen reader |
tooltip/shows-label | Must | Component | With type="label", it's aria-hidden, nothing points to it, and the trigger keeps its own name | WCAG 4.1.2 (A), Open Components | Unit test |
tooltip/never-labelledby | Must | Both | No trigger is named by its tooltip through aria-labelledby | Open Components | Unit test, review |
tooltip/named-trigger | Must | Usage | Its trigger has an accessible name of its own, like an icon-only Button's hidden label | WCAG 4.1.2 (A) | ARIA snapshot |
tooltip/in-the-dom | Must | Component | It stays in the DOM while it's closed, so its trigger's description is always there | WAI-ARIA | Unit test |
tooltip/data-state | Should | Component | Its open state shows as data-state, open or closed, so tests and agents can read it | Open Components | Unit test |
FAQ
aria-describedby adds to the name rather than replacing it, which is what type="description" does.