Tooltip

Agentic Experience (AX)

How agents read, build and check a tooltip, from its accessibility tree to its contract, its DOM and its tests.

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.

AX Checklist

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

FAQ

Copyright © 2026