Agentic Experience (AX)
Finally, let's look at how agents read, operate and check a combobox. 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 Combobox once people have typed "an" and pressed ArrowDown:
- combobox "Country" [expanded]: an
- button "Suggestions" [expanded]
- listbox "Country":
- option "France"
- option "Germany"
- option "New Zealand"
The combobox has a name and a value, and the options have a role and a name, so an agent can type and pick from it the way a person would, with no selectors:
const country = page.getByRole("combobox", { name: "Country" });
await country.fill("zeal");
await page.getByRole("option", { name: "New Zealand" }).click();
await expect(country).toHaveValue("New Zealand");
Described
Here's the whole contract in one block, for agents to read before they build or review a combobox:
component: Combobox
summary: Filters a list of suggestions as people type into a text field, and picks one option or several, keeping typed text only where you allow it, with a hidden select doing the form's work.
standard: https://opencomponents.dev/docs/components/combobox
element: div # the field, holding a text input, a hidden select, a toggle button and a popover panel
role: combobox # on the input, with aria-autocomplete="list"
props:
options: { type: "SelectItem[]" } # required. The Select's 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 }
allowCustomValue: { type: boolean, default: false } # keeps typed text as the value, for one value only
creatable: { type: boolean, default: false } # ends the list with an option that adds the typed text
createLabel: { type: function } # (text) => 'Create "Lyon"'
filter: { type: function } # (option, text) => boolean. Any part of the label, ignoring case and accents
emptyLabel: { type: string, default: No results }
resultsLabel: { type: function } # (count) => "3 results"
toggleLabel: { type: string, default: Suggestions }
removeLabel: { type: function } # (label) => "Remove France"
removedLabel: { type: function } # (label) => "France removed"
slots:
leading: An icon before the text, hidden from assistive technologies.
arrow: The chevron in the toggle, which turns as the panel opens.
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 option's label while the text shows it and in each tag, hidden from assistive technologies.
events:
create: "People picked the create option, with the text they typed."
change: "The native change event, from the select, once the value changes, never on every key."
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]"
creating: "[data-slot=option][data-create]"
keyboard:
Letters: Type and filter, opening it with nothing highlighted.
ArrowDown: Opens it on the first option, or moves the highlight, skipping disabled options. With Alt, opens it with nothing highlighted.
ArrowUp: Opens it on the last option, or moves the highlight. With Alt, closes it and keeps the text.
PageDown: Moves ten options, stopping at the end.
PageUp: Moves ten options, stopping at the start.
Enter: Picks the highlighted option and closes it, or with multiple, toggles it. With nothing highlighted, or closed, settles the text, and closed, then submits the form.
Tab: Picks the highlighted option, or settles the text, closes it and moves on. With multiple, settles the text and keeps what's toggled.
Escape: Closes it and keeps the text, then clears the text, and goes no further until there's nothing to clear.
Backspace: Deletes text. With multiple and no text, removes the last value.
Home: Moves the caret to the start.
End: Moves the caret to the end.
parts: [native, control, icon, tags, tag, tag-label, tag-remove, leading, arrow, panel, group, group-label, separator, option, option-label, check, empty] # data-slot, in this order. arrow is the toggle button. icon also comes before tag-label and option-label, and empty takes the create option's place when nothing matches
tokens:
theme: ["--color--neutral-text", "--color--focus", "--color--error-text"]
variables: [--combobox--empty--padding-block, --combobox--empty--padding-inline, --combobox--empty--color]
shared: [--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] # the Select's, set on both roots
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/combobox#checklist
It's also published on its own at /raw/docs/components/combobox.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 combobox/manual-selection, 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 Combobox with an id and a name of country, one option labelled "France" and a value of fr. Here's what it renders:
<div class="combobox" data-size="md" data-state="closed">
<select name="country" data-slot="native" tabindex="-1" aria-hidden="true">
<option value="" disabled hidden></option>
<option value="fr" selected>France</option>
</select>
<input id="country" autocomplete="off" data-slot="control" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="country-panel" value="France" spellcheck="false" autocapitalize="off" />
<button data-slot="arrow" type="button" tabindex="-1" aria-label="Suggestions" aria-expanded="false" aria-controls="country-panel">
<svg aria-hidden="true">…</svg>
</button>
<div id="country-panel" data-slot="panel" popover="manual" 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>
- The panel is the Select's, with the same markup, apart from
popover="manual", since a press in the text mustn't close it, and the Combobox closes it on a press outside the field instead. - The panel's ID is the text field's
id, or itsname, with-panelon the end, and option IDs add-option-and their position in the whole list, so an option keeps its ID as the list narrows, and a screen reader hears each new highlight. The create option's ID ends in-option-create. - States change attributes, never elements, apart from the tags, which come and go with the values, and the options, which come and go as people type.
- Once it mounts, the root gets
anchor-nameand the panelposition-anchorin itsstyle, from your framework's ID helper. While an on-screen keyboard is up, or in browsers without CSS anchor positioning, an open panel also getstop,leftandwidth, anddata-fit="visual"while the keyboard is up.
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 Combobox.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, so the tests stand in for the Popover API, scrollIntoView, requestSubmit and the visual viewport. Placing the panel against the real on-screen keyboard only happens on a phone, and anchor positioning only in a 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, with and without a match:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("the Country combobox filters, picks and keeps what people chose", async ({ page }) => {
await page.goto("/shipping");
const country = page.getByRole("combobox", { name: "Country" });
await country.fill("zeal");
await expect(country).toHaveAttribute("aria-expanded", "true");
await expect(country).not.toHaveAttribute("aria-activedescendant");
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expect(country).toHaveValue("New Zealand");
await expect(country).toBeFocused();
});
Some things still need a human, so try your Comboboxes near every edge of the window and on a real phone with its keyboard up, check that the panel flips and fits, and use them with a screen reader, on Windows and on macOS, listening for the count as you type. 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 Combobox renders without a name, without the id or name that its panel and options need, or with allowCustomValue and multiple together, which does nothing.
Prompts
You can point your agent straight at this page. To build a combobox, you could use:
Build a Combobox component for our React design system that meets the Open Components
Combobox standard: https://opencomponents.dev/raw/docs/components/combobox.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 Combobox against the Open Components Combobox contract:
https://opencomponents.dev/raw/docs/components/combobox.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 comboboxes:
Review how our shipping page uses its comboboxes against the Open Components Combobox
contract: https://opencomponents.dev/raw/docs/components/combobox.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 |
|---|---|---|---|---|---|
combobox/role-and-name | Must | Component | It can be found with getByRole("combobox", { name }), and its options with getByRole("option", { name }) | WCAG 4.1.2 (A) | Unit test, ARIA snapshot |
combobox/deterministic-dom | Must | Component | The same props render the same markup, every ID is made from its own id or name, and options keep their IDs as the list narrows | Open Components | Unit test |
combobox/data-attributes | Should | Component | Its state and size are mirrored as data-state and data-size, and parts are marked with data-slot | Open Components | Unit test |
combobox/dev-warnings | Should | Component | It warns during development when it has no name, no id or name, or allowCustomValue with multiple | Open Components | Unit test |