Switch

Agentic Experience (AX)

How agents read, build and check a switch, from its accessibility tree to its contract, its DOM and its tests.

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. The type and the role are always checkbox and switch.
  • Parts are marked with data-slot and always come in the same order: control, track and thumb, 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

RuleLevelScopeRequirementBasisCheck
switch/role-and-nameMustComponentIt 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-domMustComponentThe same props render the same markup, with no generated IDs, and states change attributes, never elementsOpen ComponentsUnit test
switch/data-attributesShouldComponentThe size is mirrored as data-size, and parts are marked with data-slotOpen ComponentsUnit test
switch/dev-warningsShouldComponentIt warns during development when a switch has no labelOpen ComponentsUnit test
Copyright © 2026