Agentic Experience (AX)
Finally, let's look at how agents read, open and check a dialog. 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 they see the Edit profile dialog once it's open, leaving out its text:
- dialog "Edit profile":
- heading "Edit profile" [level=2]
- button "Close"
- textbox "Full name": Ada Lovelace
- textbox "Email address": ada@example.com
- button "Cancel"
- button "Save changes"
While it's open, the page behind it is inert, so it's left out of the tree, and an agent only sees the dialog, as a screen reader user would. While it's closed, it's the other way round. With a role and a name, an agent can find the dialog and act inside it:
const dialog = page.getByRole("dialog", { name: "Edit profile" });
await dialog.getByRole("textbox", { name: "Full name" }).fill("Ada Lovelace");
await dialog.getByRole("button", { name: "Save changes" }).click();
Described
Here's the whole contract in one block, for agents to read before they build or review a dialog:
component: Dialog
summary: Asks for someone's full attention, on top of the page, until they answer or close it. Its title names it.
standard: https://opencomponents.dev/docs/components/dialog
element: dialog # opened with showModal(), which puts it in the top layer and makes the page inert
role: dialog # alertdialog, with role="alertdialog"
props:
open: { type: boolean, default: false } # it never changes on its own
id: { type: string } # required. Its title's and description's IDs start with it
title: { type: string } # required
description: { type: string }
role: { type: [dialog, alertdialog], default: dialog }
size: { type: [sm, md, lg], default: md }
closeLabel: { type: string, default: Close }
slots:
default: The content, like a message or a form, which scrolls when it doesn't fit.
actions: The Buttons that answer the dialog, like Cancel and Save, in the order people read them.
events:
close: "escape, close-button, backdrop or action, and the value of the button that sent a <form method=dialog>. Fires when people ask to close it, which it leaves to you. closed when it's already closed, like when the browser closed it, so open has to follow."
states:
open: "[open], and :modal once showModal() ran"
closed: ":not([open]), hidden by the browser's own styles"
keyboard:
Tab: Moves focus between its controls, and never to the page behind it.
Escape: Fires close, with the reason escape, in an alert dialog too.
parts: [header, title, close, description, body, actions] # data-slot, in this order. Only the header and the title are always there
tokens:
theme: ["--color--neutral-text"]
variables: [--dialog--width, --dialog--padding, --dialog--gap, --dialog--radius, --dialog--border-color, --dialog--background, --dialog--backdrop--background-color]
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/dialog#checklist
It's also published on its own at /raw/docs/components/dialog.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 dialog/return-focus, 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 dialog with a form and two actions:
<Dialog id="edit-profile" title="Edit profile" :open="open" @close="open = false">
<label for="name">Full name</label>
<Input id="name" autocomplete="name" />
<template #actions>
<Button variant="outline" @click="open = false">Cancel</Button>
<Button color="primary" @click="save">Save changes</Button>
</template>
</Dialog>
And here's what it renders:
<dialog class="dialog" id="edit-profile" aria-labelledby="edit-profile-title" data-size="md">
<div data-slot="header">
<h2 id="edit-profile-title" data-slot="title">Edit profile</h2>
<button class="button" type="button" data-variant="ghost" data-color="neutral" data-size="sm" 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">
<label for="name">Full name</label>
<div class="input" data-size="md">
<input data-slot="control" type="text" id="name" autocomplete="name" />
</div>
</div>
<div data-slot="actions">
<button class="button" type="button" data-variant="outline" data-color="neutral" data-size="md">
<span data-slot="label">Cancel</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="primary" data-size="md">
<span data-slot="label">Save changes</span>
</button>
</div>
</dialog>
- The size is mirrored as
data-size, which is always there. The role is only written out for an alert dialog, sincedialogis the element's own. - Parts are marked with
data-slotand always come in the same order:header, holdingtitle,closeanddescription, thenbodyandactions. Only the header and the title are always there. - The title's and the description's IDs are the dialog's own, with
-titleand-descriptionon the end. There's nothing random or generated, like IDs or timestamps, so the markup is the same on the server, on the client and in every snapshot. - Opening and closing change attributes, never elements. The dialog stays in the page while it's closed, so a form inside it keeps what people typed.
- It's never open in the server's markup. It opens with
showModal()once it's in the page, since theopenattribute alone would open it as a non-modal dialog.
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 Dialog.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 the <dialog> element but not showModal() or close(), so the tests stand in for those two. A stand-in can't put the dialog in the top layer, make the page inert or keep Tab inside, so the checklist checks those in the browser instead.
In the browser
In the browser, you can run axe to check the dialog's name and contrast, and use the keyboard the way people do, to check that focus goes in, stays in and comes back:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("the edit profile dialog takes focus and gives it back", async ({ page }) => {
await page.goto("/profile");
const opener = page.getByRole("button", { name: "Edit profile" });
await opener.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog", { name: "Edit profile" });
await expect(dialog.getByRole("textbox", { name: "Full name" })).toBeFocused();
// Tab goes round the dialog's controls, and never reaches the page behind it.
for (let i = 0; i < 6; i++) {
await page.keyboard.press("Tab");
await expect(opener).not.toBeFocused();
}
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
});
Some things still need a human, so try your dialogs with the keyboard alone, with a screen reader, which should read the title and description as focus moves in, on a phone, at 200% zoom and with forced colours, more contrast and reduced motion turned on. 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 dialog renders without a title, which leaves it without a name.
Prompts
You can point your agent straight at this page. To build a dialog, you could use:
Build a Dialog component for our React design system that meets the Open Components
Dialog standard: https://opencomponents.dev/raw/docs/components/dialog.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 Dialog against the Open Components Dialog contract:
https://opencomponents.dev/raw/docs/components/dialog.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 dialogs:
Review how our settings page uses its dialogs against the Open Components Dialog
contract: https://opencomponents.dev/raw/docs/components/dialog.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 |
|---|---|---|---|---|---|
dialog/role-and-name | Must | Component | It can be found by its role and title alone, with getByRole("dialog", { name }), or "alertdialog" | WCAG 4.1.2 (A) | Unit test, ARIA snapshot |
dialog/hidden-when-closed | Must | Component | A closed dialog isn't in the accessibility tree, so neither agents nor screen readers find it | HTML | Unit test |
dialog/deterministic-dom | Must | Component | The same props render the same markup, with its parts' IDs made from its own, and opening it changes attributes, never elements | Open Components | Unit test |
dialog/data-attributes | Should | Component | The size is mirrored as data-size, and parts are marked with data-slot | Open Components | Unit test |
dialog/dev-warnings | Should | Component | It warns during development when it has no title | Open Components | Unit test |
Developer Experience
How developers use the Dialog, with one consistent, typed API that composes with forms and other components and leaves when to open and close it to you.
Overview
How to build a text input that people can label, fill in and fix, that works with every keyboard, autofill and password manager, and that agents can check.