Agentic Experience (AX)
Finally, let's look at how agents read, operate and check a select. 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. This is how Playwright sees a Country Select once it's open:
- combobox "Country" [expanded]: France
- listbox "Country":
- option "France" [selected]
- option "Germany"
- option "Spain"
The combobox has a name and a value, and the options have a role and a name, so an agent can pick from it the way a person would, with no selectors:
await page.getByRole("combobox", { name: "Country" }).click();
await page.getByRole("option", { name: "Spain" }).click();
await expect(page.getByRole("combobox", { name: "Country" })).toHaveText("Spain");
On a coarse pointer, the native <select> is the control instead, so an agent reads a combobox there as well and picks with selectOption.
Described
Here's the whole contract in one block, for agents to read before they build or review a select:
component: Select
summary: Picks one option, or several, out of a list, in a panel you can restyle, with a native select doing the form's work and showing the device's own picker on touch screens.
standard: https://opencomponents.dev/docs/components/select
element: div # the field, holding a button, a select and a popover panel
role: combobox # on the button
props:
options: { type: "SelectItem[]" } # required. Options, { label, options } groups and { separator: true }
value: { type: string } # or string[] with multiple, bound or not
multiple: { type: boolean, default: false }
placeholder: { type: string }
size: { type: [xs, sm, md, lg, xl], default: md }
disabled: { type: boolean, default: false }
readonly: { type: boolean, default: false }
required: { type: boolean, default: false }
nativeOnTouch: { type: boolean, default: true } # the native select on a coarse primary pointer
removeLabel: { type: function } # (label) => "Remove France"
removedLabel: { type: function } # (label) => "France removed"
countLabel: { type: function } # (count) => "4 selected"
slots:
leading: An icon before the value, hidden from assistive technologies. Stays in the native rendering.
value: How the picked value reads, given selected.
arrow: The chevron, hidden from assistive technologies. Stays in the native rendering.
option: How an option reads, given option, selected and active. The check mark stays beside it.
tag: How a picked value reads in its tag, given option. The remove button stays beside it.
group-label: How a group's heading reads, given label.
separator: The rule between options or groups.
icon: An option's icon, given option, shown in the panel, beside the picked value and in each tag, hidden from assistive technologies.
events:
change: "The native change event, from the select, for a choice in the panel or the native picker."
input: "The native input event, from the select, before change."
states:
open: "[data-state=open]"
closed: "[data-state=closed]"
disabled: "[data-slot=control]:disabled"
read-only: "[data-slot=control][aria-readonly=true]"
invalid: "[data-slot=control][aria-invalid=true]"
highlighted: "[data-slot=option][data-active=true]"
selected: "[data-slot=option][aria-selected=true]"
native: "[data-native], where the primary pointer is coarse"
keyboard:
ArrowDown: Opens it, or moves the highlight, skipping disabled options.
ArrowUp: Opens it on the first option, or moves the highlight. With Alt, picks and closes, or with multiple, closes and keeps what's toggled.
Home: Opens it on the first option, or moves there.
End: Opens it on the last option, or moves there.
PageDown: Moves ten options, stopping at the end.
PageUp: Moves ten options, stopping at the start.
Enter: Opens it, or picks and closes. With multiple, toggles and stays open.
Space: The same as Enter, unless typing.
Tab: Picks the highlighted option and moves on. With multiple, closes and keeps what's toggled.
Escape: Closes it, keeps the value and goes no further.
Backspace: With multiple, on the closed control, removes the last value.
parts: [native, control, icon, value, tags, tag, tag-label, tag-remove, leading, arrow, panel, group, group-label, separator, option, option-label, check] # data-slot, in this order. control is on the button in the custom rendering and the select in the native one, and native on the select in the custom rendering
tokens:
theme: ["--color--neutral-text", "--color--focus", "--color--error-text"]
variables: [--select--height, --select--padding, --select--gap, --select--font-size, --select--radius, --select--border-color, --select--background, --select--icon--size, --select--leading--color, --select--value--placeholder-color, --select--arrow--size, --select--arrow--color, --select--panel--offset, --select--panel--padding, --select--panel--max-height, --select--panel--radius, --select--panel--border-color, --select--panel--background, --select--panel--box-shadow, --select--option--padding-block, --select--option--padding-inline, --select--option--radius, --select--option--color, --select--option--hover--background, --select--option--selected--background, --select--option--selected--font-weight, --select--option--disabled--opacity, --select--tag--height, --select--tag--padding, --select--tag--radius, --select--tag--font-size, --select--tag--background, --select--tag--color, --select--tag--icon--size, --select--group-label--padding-block, --select--group-label--padding-inline, --select--group-label--font-size, --select--group-label--font-weight, --select--group-label--color, --select--separator--color, --select--separator--margin]
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/select#checklist
It's also published on its own at /raw/docs/components/select.yaml, with every rule from the checklist in place of the rules link. It's less than a tenth 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 select/no-auto-pick, 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, take a Select with an id and a name of country, one option labelled "France" and a value of fr. Here's what it renders on a fine pointer, after it mounts:
<div class="select" data-size="md" data-state="closed" data-native>
<select name="country" data-slot="native" tabindex="-1" aria-hidden="true">
<option value="" disabled hidden></option>
<option value="fr" selected>France</option>
</select>
<button id="country" data-slot="control" type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="country-panel">
<span data-slot="value">France</span>
</button>
<span data-slot="arrow" aria-hidden="true"><svg>…</svg></span>
<div id="country-panel" data-slot="panel" popover="auto" role="listbox">
<div role="none">
<div id="country-option-0" data-slot="option" role="option" aria-selected="true">
<span data-slot="option-label">France</span>
<svg data-slot="check" aria-hidden="true">…</svg>
</div>
</div>
</div>
</div>
- Both controls are always there. In the server's markup with the fallback on,
id, ARIA anddata-slot="control"sit on the<select>, so a phone's label works before any script runs, and a fine pointer moves them to the button on mount. The<select>'stabindexandaria-hiddencome off in the native rendering. - States change attributes, never elements, apart from the tags, which come and go with the values.
- The panel's ID is the control's
id, or itsname, with-panelon the end, and option IDs add-option-and their position. There's nothing random in the markup, so it's the same on the server, on the client and in every snapshot. - Once it mounts, the root gets
anchor-nameand the panelposition-anchorin itsstyle, from your framework's ID helper, since anidcan hold characters a CSS name can't. In browsers without CSS anchor positioning, an open panel also getstop,leftandwidth.
Verifiable
Every rule in the checklist can be checked, and most of them automatically.
Unit tests
Every rule that the checklist checks with a unit test has one in Select.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. One suite runs against a coarse pointer and a fine one, so the value, the form and the Field are checked in both renderings.
They run in jsdom, so the tests stand in for the Popover API, matchMedia and scrollIntoView, and leave a disabled selected option out of the form data, which jsdom gets wrong. Placing the panel and the browser's own pickers only exist in a real browser, so check those there.
In the browser
You can use the keyboard and the pointer the way people would, and run axe over the open panel:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("the Country select opens from the keyboard, picks and closes", async ({ page }) => {
await page.goto("/shipping");
const country = page.getByRole("combobox", { name: "Country" });
await country.focus();
await page.keyboard.press("ArrowDown");
await expect(country).toHaveAttribute("aria-expanded", "true");
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
await page.keyboard.press("s");
await page.keyboard.press("Enter");
await expect(country).toHaveText("Spain");
await expect(country).toBeFocused();
});
Some things still need a human, so try your Selects near every edge of the window and on a phone, check that the panel flips and fits, and use them with a screen reader, on Windows and on macOS, since a native select maps to different roles on each. 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 when a Select renders without a name, or without the id or name that its panel and options need.
Prompts
You can point your agent straight at this page. To build a select, you could use:
Build a Select component for our React design system that meets the Open Components
Select standard: https://opencomponents.dev/raw/docs/components/select.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 Select against the Open Components Select contract:
https://opencomponents.dev/raw/docs/components/select.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 selects:
Review how our shipping page uses its selects against the Open Components Select
contract: https://opencomponents.dev/raw/docs/components/select.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 |
|---|---|---|---|---|---|
select/role-and-name | Must | Component | It can be found with getByRole("combobox", { name }), in both renderings | WCAG 4.1.2 (A) | Unit test, ARIA snapshot |
select/deterministic-dom | Must | Component | The same props render the same markup, with every ID made from its own id or name, and states change attributes, never elements | Open Components | Unit test |
select/data-attributes | Should | Component | Its state, size and native mode are mirrored as data-state, data-size and data-native, and parts are marked with data-slot | Open Components | Unit test |
select/dev-warnings | Should | Component | It warns during development when it has no name, or no id or name | Open Components | Unit test |