Agentic Experience (AX)
Finally, let's look at how agents read, open and check a popover. 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 states. This is how Playwright sees the Filters popover once it's open, leaving out the checkboxes' text:
- button "Filters" [expanded]
- dialog "Filters":
- paragraph: Filters
- button "Close"
- checkbox "Open issues only" [checked]
- checkbox "Assigned to me"
- checkbox "Has a pull request"
Playwright works roles and states out for itself, from attributes, so it reads the trigger's aria-expanded, which the Popover sets, but not its aria-haspopup. The browser's own tree, which screen readers read and Chrome DevTools shows under Accessibility, has both. While it's closed, the trigger reads as collapsed, and the popover isn't in the tree at all. With a role and a name on both, an agent can open the popover and act inside it:
await page.getByRole("button", { name: "Filters" }).click();
const filters = page.getByRole("dialog", { name: "Filters" });
await filters.getByRole("checkbox", { name: "Assigned to me" }).check();
await page.keyboard.press("Escape");
Described
Here's the whole contract in one block, for agents to read before they build or review a popover:
component: Popover
summary: Shows more next to the button that opened it, like filters or a short form, without blocking the rest of the page. Its title names it, and a Button with popovertarget opens it.
standard: https://opencomponents.dev/docs/components/popover
element: div # popover="auto", right after its trigger, and in the top layer while it's open
role: dialog # without aria-modal, since the page around it keeps working
props:
open: { type: boolean, default: false } # it follows every open and close, so bind it both ways
id: { type: string } # required. Its trigger's popovertarget points to it, and its title's ID starts with it
title: { type: string } # required
placement: { type: [top, bottom, start, end], default: bottom }
arrow: { type: boolean, default: true }
closeLabel: { type: string, default: Close }
slots:
default: The content, like a few options, a short form or a hint with a link, which scrolls when it doesn't fit.
events:
close: "escape, outside, close-button, trigger or closed. Fires once it has closed, unless open closed it."
toggle: "The browser's own ToggleEvent, with newState open or closed, for every open and close."
states:
open: "[data-state=open], and :popover-open"
closed: "[data-state=closed], hidden by the browser's own styles"
keyboard:
Enter: On its trigger, opens it, or closes it when it's open. Focus moves to data-autofocus, or else the first control in its body, then its close button. The trigger's aria-expanded follows.
Space: On its trigger, the same as Enter.
Tab: Moves between its controls, then on to what comes after its trigger, leaving it open.
Escape: Closes it, wherever focus is, and stops there, giving focus back to its trigger when it was inside. A popover open inside it, or a control that handles Escape itself, goes first.
parts: [header, title, close, body, arrow] # data-slot, in this order. The arrow is there unless arrow is false
tokens:
theme: ["--color--neutral-text"]
variables: [--popover--width, --popover--padding, --popover--gap, --popover--radius, --popover--border-color, --popover--background, --popover--box-shadow, --popover--offset, --popover--arrow--size]
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/popover#checklist
It's also published on its own at /raw/docs/components/popover.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 popover/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 a popover with a short hint:
<Popover id="filters" title="Filters">
<p>Pick what to show.</p>
</Popover>
And here's what it renders, before it opens:
<div class="popover" id="filters" role="dialog" aria-labelledby="filters-title" popover="auto" data-state="closed" data-placement="bottom">
<div data-slot="header">
<p id="filters-title" data-slot="title">Filters</p>
<button class="button" type="button" data-variant="ghost" data-color="neutral" data-size="xs" data-icon-only data-slot="close">
<span data-slot="leading" aria-hidden="true"><svg>…</svg></span>
<span data-slot="label">Close</span>
</button>
</div>
<div data-slot="body">
<p>Pick what to show.</p>
</div>
<span data-slot="arrow" aria-hidden="true"></span>
</div>
data-stateanddata-placementare always there. Unlessarrowisfalse, the popover ends with a<span data-slot="arrow" aria-hidden="true">, 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.- Parts are marked with
data-slotand always come in the same order:header, holdingtitleandclose, thenbodyandarrow. Only the body and the arrow can be left out. - The title's ID is the popover's own, with
-titleon the end. There's nothing random in the server's markup, so it's the same on the server, on the client and in every snapshot. - Once it mounts, every trigger that points at it gets
aria-expanded="false", and the one that opens it"true"while it's open. The popover getsposition-anchorin itsstyle, and the trigger that opens it gets the matchinganchor-nameuntil it closes. The name comes from your framework's ID helper, since anidcan hold characters a CSS name can't, like1:2. In browsers without CSS anchor positioning, an open popover also getstopandleft, from the script that places it. - Opening and closing change attributes, never elements. The popover stays in the page while it's closed, so a form inside it keeps what people typed.
Verifiable
Every rule in the checklist can be checked, and about half of them automatically.
Unit tests
Every rule that the checklist checks with a unit test has one in Popover.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 run in jsdom, which has no Popover API, so the tests stand in for it: showing and hiding with beforetoggle and toggle, a popovertarget button opening its popover, and the closes the browser makes itself, for Escape and for a press outside. The Escape tests mount a real Dialog and a real Tooltip. A stand-in can't put the popover in the top layer or place it, so the checklist checks those in the browser instead.
In the browser
Placement, flipping, the top layer and light dismiss only exist in a real browser, so check them there. You can use the keyboard and the pointer the way people would, and run axe over the open popover:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("the Filters popover takes focus, closes on Escape and gives focus back", async ({ page }) => {
await page.goto("/issues");
const trigger = page.getByRole("button", { name: "Filters" });
await trigger.focus();
await page.keyboard.press("Enter");
const filters = page.getByRole("dialog", { name: "Filters" });
await expect(trigger).toHaveAttribute("aria-haspopup", "dialog");
await expect(filters.getByRole("checkbox", { name: "Open issues only" })).toBeFocused();
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(filters).toBeHidden();
await expect(trigger).toBeFocused();
// A press outside closes it too, and leaves focus where it landed.
await trigger.click();
await page.mouse.click(5, 5);
await expect(filters).toBeHidden();
});
Some things still need a human, so try your popovers near every edge of the window and on a phone, check that they flip and stay inside the screen, and use them with a screen reader, which should read the title as focus moves in. Try them at 200% zoom and with forced colours, more contrast and reduced motion turned on too. You can emulate the last three in Chrome DevTools, under Rendering.
During development
Our reference implementation also warns you in the console as soon as a popover renders without a title, which leaves it without a name.
Prompts
You can point your agent straight at this page. To build a popover, you could use:
Build a Popover component for our React design system that meets the Open Components
Popover standard: https://opencomponents.dev/raw/docs/components/popover.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 Popover against the Open Components Popover contract:
https://opencomponents.dev/raw/docs/components/popover.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 popovers:
Review how our issues page uses its popovers against the Open Components Popover
contract: https://opencomponents.dev/raw/docs/components/popover.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 |
|---|---|---|---|---|---|
popover/role-and-name | Must | Component | It can be found by its role and title alone, with getByRole("dialog", { name }) | WCAG 4.1.2 (A) | Unit test, ARIA snapshot |
popover/hidden-when-closed | Must | Component | A closed popover isn't in the accessibility tree, so neither agents nor screen readers find it | HTML | Unit test |
popover/deterministic-dom | Must | Component | The same props render the same markup, with its title's ID made from its own, and opening it changes attributes, never elements | Open Components | Unit test |
popover/data-attributes | Should | Component | Its state and placement are mirrored as data-state and data-placement, and parts are marked with data-slot | Open Components | Unit test |
popover/dev-warnings | Should | Component | It warns during development when it has no title | Open Components | Unit test |
Developer Experience
How developers use the Popover, opened by a Button with popovertarget, with one typed API and an open state that follows every open and close.
Overview
How to build a radio group that asks one clear question, lets people pick one answer with a pointer or the arrow keys, says what's wrong when they skip it, and that agents can check.