Popover

Agentic Experience (AX)

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

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-state and data-placement are always there. Unless arrow is false, the popover ends with a <span data-slot="arrow" aria-hidden="true">, 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.
  • Parts are marked with data-slot and always come in the same order: header, holding title and close, then body and arrow. Only the body and the arrow can be left out.
  • The title's ID is the popover's own, with -title on 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 gets position-anchor in its style, and the trigger that opens it gets the matching anchor-name until it closes. The name comes from your framework's ID helper, since an id can hold characters a CSS name can't, like 1:2. In browsers without CSS anchor positioning, an open popover also gets top and left, 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

RuleLevelScopeRequirementBasisCheck
popover/role-and-nameMustComponentIt 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-closedMustComponentA closed popover isn't in the accessibility tree, so neither agents nor screen readers find itHTMLUnit test
popover/deterministic-domMustComponentThe same props render the same markup, with its title's ID made from its own, and opening it changes attributes, never elementsOpen ComponentsUnit test
popover/data-attributesShouldComponentIts state and placement are mirrored as data-state and data-placement, and parts are marked with data-slotOpen ComponentsUnit test
popover/dev-warningsShouldComponentIt warns during development when it has no titleOpen ComponentsUnit test
Copyright © 2026