Agentic Experience (AX)
Finally, let's look at how agents read, fill in and check a radio 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, values and states. This is how they see the examples under States, in Chrome:
- radiogroup "Delivery":
- text: Delivery
- radio "Standard" [checked]
- text: Standard
- radio "Express"
- text: Express
- radiogroup "Shipping":
- text: Shipping
- radio "Standard" [checked]
- text: Standard
- radio "Collect from a store" [disabled]
- text: Collect from a store
- radiogroup "Plan":
- text: Plan
- radio "Personal" [disabled]
- text: Personal
- radio "Team" [checked] [disabled]
- text: Team
- radiogroup "How should we contact you?" [invalid]:
- text: How should we contact you?
- paragraph: Choose how we should contact you
- radio "Email"
- text: Email
- radio "Phone"
- text: Phone
The legend names the group, through aria-labelledby, and the group carries [invalid], since it has the radiogroup role. A group without a legend is a radio group with no name, so an agent can't tell the delivery options from the payment options, any more than a screen reader user can. With a role and a name, an agent can find the group, and the radio inside it, and check it:
await page.getByRole("radiogroup", { name: "Delivery" }).getByRole("radio", { name: "Express" }).check();
Described
Here's the whole contract in one block, for agents to read before they build or review a radio group:
component: RadioGroup
summary: Asks one question with a few answers that all fit on the screen, and lets people pick one. Its legend names it, and each Radio inside it is an answer.
standard: https://opencomponents.dev/docs/components/radio-group
element: fieldset # with role=radiogroup, around one Radio for each option
role: radiogroup # each radio is an <input type="radio">, with the radio role
props:
name: { type: string } # required. Every radio submits under it, and the legend's, hint's and error's IDs start with it
legend: { type: string } # required. The question, which names the group
hint: { type: string }
error: { type: string } # while it's set, the group is invalid
size: { type: [xs, sm, md, lg, xl], default: md }
orientation: { type: [vertical, horizontal], default: vertical } # horizontal only for two short options, and never with aria-orientation
disabled: { type: boolean, default: false }
required: { type: boolean, default: false }
value: { type: string } # the checked radio's value, when it's bound. Without it, the group keeps its own
slots:
default: The radios, one Radio for each option.
events:
change: Event, from the radio people checked. It bubbles up to the group.
states:
hover: ":hover on a radio, in @media (hover: hover)"
focus: "input:focus-visible"
checked: ":has(> input:checked) on a radio"
disabled: ":has(> input:disabled) on a radio, which every radio in a disabled group matches"
invalid: "[aria-invalid=true] on the group, never :invalid"
keyboard:
Tab: Moves focus into the group, onto the checked radio or the first one, and out of it again.
ArrowDown: Moves focus to the next radio and checks it, skipping disabled ones. Wraps from the last to the first. ArrowRight too.
ArrowUp: Moves focus to the previous radio and checks it. Wraps from the first to the last. ArrowLeft too.
Space: Checks the radio with focus.
parts: [legend, hint, error, options] # data-slot, in this order. The options hold one radio for each option. Each radio is a Radio, a <label> with the parts [control, label], the props value, disabled and checked, and the variables --radio--height, --radio--gap, --radio--font-size, --radio--indicator--size, --radio--indicator--border-color, --radio--indicator--fill and --radio--dot--color
tokens:
theme: ["--color--neutral-text", "--color--primary", "--color--primary-contrast", "--color--error-text", "--color--focus"]
variables: [--radio-group--gap, --radio-group--font-size, --radio-group--options--column-gap]
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/radio-group#checklist
It's also published on its own at /raw/docs/components/radio-group.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 radio-group/fieldset-legend, 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 required group with a hint, an error and an answer:
<RadioGroup
name="delivery"
legend="Delivery"
hint="Express arrives the next working day"
error="Choose how you'd like your order delivered"
required
value="express"
>
<Radio value="standard">Standard</Radio>
<Radio value="express">Express</Radio>
</RadioGroup>
And here's what it renders:
<fieldset
class="radio-group"
role="radiogroup"
data-size="md"
data-orientation="vertical"
aria-labelledby="delivery-legend"
aria-invalid="true"
aria-describedby="delivery-hint delivery-error"
>
<legend id="delivery-legend" data-slot="legend">Delivery</legend>
<p id="delivery-hint" data-slot="hint">Express arrives the next working day</p>
<p id="delivery-error" data-slot="error">Choose how you'd like your order delivered</p>
<div data-slot="options">
<label class="radio" data-slot="radio" data-size="md">
<input data-slot="control" type="radio" name="delivery" value="standard" required />
<span data-slot="label">Standard</span>
</label>
<label class="radio" data-slot="radio" data-size="md">
<input data-slot="control" type="radio" name="delivery" value="express" checked required />
<span data-slot="label">Express</span>
</label>
</div>
</fieldset>
- The size is mirrored as
data-sizeon the group and on every radio, and the orientation asdata-orientationon the group, and both are always there. The role is alwaysradiogroup, so an agent finds every group the same way. - Parts are marked with
data-slotand always come in the same order:legend,hint,errorandoptions, which holds the radios, each with itscontrolandlabel. Only the legend, the options and the radios are always there. - The legend's, hint's and error's IDs are the group's
namewith-legend,-hintand-error, so the markup is the same on the server, on the client and in every snapshot. There's nothing random or generated, like IDs or timestamps. - States change attributes, and the error message comes and goes, but the radios are never replaced, so focus, the checked radio and the screen reader's position all survive every state change, an error included.
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 RadioGroup.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 on jsdom, which proves the markup and the behaviour the browser builds on, but not every browser's own keyboard handling. So once you've built your Radio Group, check it with the keyboard in each browser you support: Tab and Shift+Tab into and out of a group, with and without a checked radio, all four arrow keys, Space and a disabled radio.
In the browser
In the browser, you can run axe to check names and contrast, and answer the form the way an agent would, to check what happens when people skip the question:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("the checkout asks how to deliver, and says so when it's skipped", async ({ page }) => {
await page.goto("/checkout");
const delivery = page.getByRole("radiogroup", { name: "How would you like it delivered?" });
await page.getByRole("button", { name: "Place order" }).click();
await expect(delivery).toHaveAttribute("aria-invalid", "true");
await expect(delivery).toHaveAccessibleDescription(/Choose how/);
await expect(delivery.getByRole("radio", { name: "Standard" })).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(delivery.getByRole("radio", { name: "Express" })).toBeChecked();
await expect(delivery).not.toHaveAttribute("aria-invalid");
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
});
Some things still need a human, so try your forms with a screen reader, to hear the legend, the hint and the error as focus enters each group, on a phone, 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, or a Radio renders outside a group, which are the two ways a radio group loses its question.
Prompts
You can point your agent straight at this page. To build a radio group, you could use:
Build a Radio Group component for our React design system that meets the Open Components
Radio Group standard: https://opencomponents.dev/raw/docs/components/radio-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 Radio Group against the Open Components Radio Group contract:
https://opencomponents.dev/raw/docs/components/radio-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 radio groups:
Review how our checkout page uses its radio groups against the Open Components Radio
Group contract: https://opencomponents.dev/raw/docs/components/radio-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 |
|---|---|---|---|---|---|
radio-group/role-and-name | Must | Component | It can be found by its role and legend alone, with getByRole("radiogroup", { name }), and each radio with getByRole("radio", { name }) | WCAG 4.1.2 (A) | Unit test |
radio-group/deterministic-dom | Must | Component | The same props render the same markup, with no generated IDs, and states never replace the radios | Open Components | Unit test |
radio-group/data-attributes | Should | Component | The size is mirrored as data-size on the group and every radio, the orientation as data-orientation on the group, and parts are marked with data-slot | Open Components | Unit test |
radio-group/dev-warnings | Should | Component | It warns during development when a group has no legend, or a radio is outside a group | Open Components | Unit test |