Agentic Experience (AX)
Finally, let's look at how agents read, check and verify a checkbox group. 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 select-all example on this page:
- group "Which emails would you like?":
- checkbox "All emails" [checked=mixed]
- checkbox "Product news" [checked]
- checkbox "Tips and guides"
- checkbox "Events near you"
A heading or a paragraph above some boxes is only text, so an agent can't tell which boxes belong to which question, any more than a screen reader user can. With a named group, an agent can find the question, pick from it and confirm what it picked:
const emails = page.getByRole("group", { name: "Which emails would you like?" });
await emails.getByRole("checkbox", { name: "All emails" }).check();
for (const box of await emails.getByRole("checkbox").all()) await expect(box).toBeChecked();
Described
Here's the whole contract in one block, for agents to read before they build or review a checkbox group:
component: CheckboxGroup
summary: Lets people pick any number of options from a short list. Its legend names it, and each option is a Checkbox.
standard: https://opencomponents.dev/docs/components/checkbox-group
element: fieldset # its first child is the legend that names it. It takes every attribute that isn't a prop
role: group
props:
options: { type: "{ value: string, label: string }[]" } # a Checkbox for each, in this order
name: { type: string } # shared by every box, so the form sends one field with a value per checked option
value: { type: "string[]", default: [] } # the checked options' values, in their order, when it's bound. Without it, the group keeps its own
size: { type: [xs, sm, md, lg, xl], default: md }
orientation: { type: [vertical, horizontal], default: vertical }
disabled: { type: boolean, default: false } # sets disabled on the fieldset
selectAll: { type: string } # the label of a box above the options that checks or clears them all
slots:
default: The legend, which names the group.
events:
change: Event, from the box that changed, which bubbles up to the fieldset.
states:
disabled: ":disabled, from <fieldset disabled>, which every box inside it matches too"
invalid: "[aria-invalid=true], which each option gets too, never :invalid"
select-all: "> [data-slot=select-all] input:checked when every option is, :indeterminate when only some are"
keyboard:
Tab: Moves focus to the next box, select-all box included. There's no roving focus.
Space: Checks or unchecks the focused box. On the select-all box, it checks every option, or clears them all.
parts: [legend, select-all, options] # data-slot, in this order. Only select-all is optional
tokens:
theme: ["--color--neutral-text"] # and the Checkbox's, through its boxes
variables: [--checkbox-group--legend--font-size, --checkbox-group--legend--margin-bottom, --checkbox-group--indent, --checkbox-group--row-gap, --checkbox-group--column-gap]
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/checkbox-group#checklist
It's also published on its own at /raw/docs/components/checkbox-group.yaml, with every rule from the checklist in place of the rules link. Its boxes follow the Checkbox's contract too, so give agents both.
Every rule has a stable ID, like checkbox-group/select-all-state, 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 group with a select-all box and two options, "Product news" (news) and "Tips and guides" (tips), described by its hint:
<CheckboxGroup name="emails" selectAll="All emails" aria-describedby="emails-hint">
Which emails would you like?
</CheckboxGroup>
And here's what it renders:
<fieldset class="checkbox-group" aria-describedby="emails-hint" data-size="md" data-orientation="vertical">
<legend data-slot="legend">Which emails would you like?</legend>
<div data-slot="select-all">
<label class="checkbox" data-size="md">
<input data-slot="box" type="checkbox" />
<svg data-slot="indicator" viewBox="0 0 16 16" aria-hidden="true">
<path data-mark="check" d="M3.5 8.5 6.5 11.5 12.5 4.5" />
<path data-mark="dash" d="M4 8h8" />
</svg>
<span data-slot="label">All emails</span>
</label>
</div>
<div data-slot="options">
<label class="checkbox" data-size="md">
<input data-slot="box" type="checkbox" name="emails" value="news" />
<svg data-slot="indicator" viewBox="0 0 16 16" aria-hidden="true">…</svg>
<span data-slot="label">Product news</span>
</label>
<label class="checkbox" data-size="md">
<input data-slot="box" type="checkbox" name="emails" value="tips" />
<svg data-slot="indicator" viewBox="0 0 16 16" aria-hidden="true">…</svg>
<span data-slot="label">Tips and guides</span>
</label>
</div>
</fieldset>
- The size and the orientation are mirrored as
data-sizeanddata-orientation, which are always there, and every box takes the group'sdata-size. - Parts are marked with
data-slotand always come in the same order:legend,select-allandoptions. Only the select-all box is optional, and each box inside follows the Checkbox's DOM contract. - States change properties and attributes, never elements. The select-all box's mixed state is a property of its
<input>, so it never shows up in the markup, and no box is ever replaced as people check and uncheck them, so focus and the screen reader's position survive every change. - 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. The legend names the
<fieldset>without an ID. The select-all box has noaria-controls, which the APG's example gives it. We leave it out on purpose, since it would need an ID on every option, and the<fieldset>already ties the box to the options inside it.
Verifiable
Every rule in the checklist can be checked, and about two thirds of them automatically.
Unit tests
Every rule that the checklist checks with a unit test has one in CheckboxGroup.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.
In the browser
In the browser, you can run axe to check labels and contrast, and use the keyboard and the select-all box the way an agent would:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("the emails group is named, reached one box at a time, and its select-all box checks them all", async ({ page }) => {
await page.goto("/preferences");
const emails = page.getByRole("group", { name: "Which emails would you like?" });
const all = emails.getByRole("checkbox", { name: "All emails" });
await all.focus();
await page.keyboard.press("Tab");
await expect(emails.getByRole("checkbox", { name: "Product news" })).toBeFocused();
await page.keyboard.press("Space");
await expect(all).toBeChecked({ indeterminate: true });
await all.check();
for (const box of await emails.getByRole("checkbox").all()) await expect(box).toBeChecked();
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
});
Some things still need a human, so try your groups with the keyboard alone, with a screen reader, which should announce the legend as you move into the group and the select-all box as mixed, and at 200% zoom, with forced colours and more contrast turned on. You can emulate the last two in Chrome DevTools, under Rendering.
During development
Our reference implementation also warns you in the console as soon as a group renders without a legend, which leaves screen reader users with a list of boxes and no question.
Prompts
You can point your agent straight at this page. To build a checkbox group, you could use:
Build a Checkbox Group component for our React design system that meets the Open Components
Checkbox Group standard: https://opencomponents.dev/raw/docs/components/checkbox-group.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 Checkbox Group against the Open Components Checkbox Group contract:
https://opencomponents.dev/raw/docs/components/checkbox-group.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 checkbox groups:
Review how our preferences page uses its checkbox groups against the Open Components
Checkbox Group contract: https://opencomponents.dev/raw/docs/components/checkbox-group.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 |
|---|---|---|---|---|---|
checkbox-group/role-and-name | Must | Component | It can be found by its legend alone, with getByRole("group", { name }), and each option inside it with getByRole("checkbox", { name }) | WCAG 4.1.2 (A) | Unit test |
checkbox-group/deterministic-dom | Must | Component | The same props render the same markup, with no generated IDs, and states change properties and attributes, never elements | Open Components | Unit test |
checkbox-group/data-attributes | Should | Component | The size and the orientation are mirrored as data-size and data-orientation, and parts are marked with data-slot | Open Components | Unit test |
checkbox-group/dev-warnings | Should | Component | It warns during development when a group has no legend | Open Components | Unit test |
Developer Experience
How developers use the Checkbox Group, with one consistent, typed API that's built from Checkboxes and works with your forms however you build them.
Overview
How to build a modal dialog that takes focus and gives it back, that everyone can close, that developers control and that agents can check.