Agentic Experience (AX)
Finally, let's look at how agents read, flip and check a switch. 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 examples under States:
- switch "Wi-Fi"
- switch "Bluetooth" [checked]
- switch "Sync" [checked] [disabled]
- switch "Single sign-on" [checked]
- switch "Price alerts" [invalid]
A switch without a label is a switch with no name, so an agent can't tell Wi-Fi from Bluetooth, any more than a screen reader user can. With a role and a name, an agent can find the switch, flip it and check its state:
const wifi = page.getByRole("switch", { name: "Wi-Fi" });
await wifi.check();
await expect(wifi).toBeChecked();
Playwright's snapshot leaves aria-readonly out, so a read-only switch looks like any other there. Check it with toHaveAttribute("aria-readonly", "true") instead.
Described
Here's the whole contract in one block, for agents to read before they build or review a switch:
component: Switch
summary: Turns a setting on or off, with an effect that applies right away. Its label names the setting.
standard: https://opencomponents.dev/docs/components/switch
element: input # type=checkbox, inside a span with the track and thumb, which takes class and style
role: switch
props:
size: { type: [xs, sm, md, lg, xl], default: md }
disabled: { type: boolean, default: false }
readonly: { type: boolean, default: false } # sets aria-readonly, and cancels the click that would flip it
checked: { type: boolean, default: false } # when it's bound. Without it, the switch keeps its own
events:
change: Event, from the <input>. Fires when people flip it.
states:
checked: "input:checked"
hover: ":hover, in @media (hover: hover)"
focus: "input:focus-visible"
disabled: "input:disabled"
read-only: "input[aria-readonly=true]"
invalid: "input[aria-invalid=true]"
keyboard:
Tab: Moves focus to the switch.
Space: Flips it on or off.
parts: [control, track, thumb] # data-slot, in this order. The control is the <input>, and the thumb is inside the track
tokens:
theme: ["--color--neutral-text", "--color--primary", "--color--primary-contrast", "--color--error-text", "--color--focus"]
variables: [--switch--track--width, --switch--track--height, --switch--track--border-color, --switch--track--background-color, --switch--thumb--size, --switch--thumb--offset, --switch--thumb--background-color]
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/switch#checklist
It's also published on its own at /raw/docs/components/switch.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 switch/stable-label, 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 switch that's on:
<Switch id="wifi" name="wifi" checked />
And here's what it renders:
<span class="switch" data-size="md">
<input data-slot="control" id="wifi" name="wifi" type="checkbox" role="switch" checked />
<span data-slot="track" aria-hidden="true">
<span data-slot="thumb"></span>
</span>
</span>
- The size is mirrored as
data-size, which is always there. Thetypeand theroleare alwayscheckboxandswitch. - Parts are marked with
data-slotand always come in the same order:control,trackandthumb, which is inside the track. All three are always there. - States change attributes, never elements. The
<input>is never replaced, so focus and the screen reader's position survive every state change, an error included. - 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. You give the switch its
id, which the Field will do for you.
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 Switch.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, flip the switch the way an agent would, and emulate reduced motion to check that the thumb jumps rather than slides:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("the settings page turns dark mode on and says so", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/settings");
const darkMode = page.getByRole("switch", { name: "Dark mode" });
await darkMode.press("Space");
await expect(darkMode).toBeChecked();
await expect(page.getByRole("status")).toHaveText("Dark mode is on");
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
});
Some things still need a human, so try your settings with the keyboard alone, with a screen reader, on a phone and at 200% zoom, in a right-to-left language, and 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 switch renders without a label, which leaves screen readers announcing nothing but "switch, off".
Prompts
You can point your agent straight at this page. To build a switch, you could use:
Build a Switch component for our React design system that meets the Open Components
Switch standard: https://opencomponents.dev/raw/docs/components/switch.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 Switch against the Open Components Switch contract:
https://opencomponents.dev/raw/docs/components/switch.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 switches:
Review how our settings page uses its switches against the Open Components Switch
contract: https://opencomponents.dev/raw/docs/components/switch.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 |
|---|---|---|---|---|---|
switch/role-and-name | Must | Component | It can be found by its role, its label and its state alone, with getByRole("switch", { name, checked }) | WCAG 4.1.2 (A) | Unit test |
switch/deterministic-dom | Must | Component | The same props render the same markup, with no generated IDs, and states change attributes, never elements | Open Components | Unit test |
switch/data-attributes | Should | Component | The size is mirrored as data-size, and parts are marked with data-slot | Open Components | Unit test |
switch/dev-warnings | Should | Component | It warns during development when a switch has no label | Open Components | Unit test |
Developer Experience
How developers use the Switch, with one consistent, typed API that works with your forms and leaves its state to you or the browser.
Overview
How to build a textarea that people can label, write in at length and fix, that grows or resizes without cutting anything off, and that agents can check.