Switch
Switches turn a setting on or off, like Wi-Fi, dark mode or email notifications, and the change happens as soon as you flip one. They're small and familiar, which is why they're so easy to get wrong. Think of a switch whose only difference between on and off is its colour, a label that flips between "On" and "Off" so you can't tell what the setting is, or a switch in a form that does nothing until you find the Save button at the bottom.
This page walks you through building one the right way. We'll look at:
- User Interface (UI) - how a switch looks
- User Experience (UX) - how it behaves for every person and every input
- Developer Experience (DX) - how developers work with it
- Agentic Experience (AX) - how agents can read and verify it
Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to flip them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Switch with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.
The Switch is the control you flip. Its label, hint and error message are elements of their own, which you link to it by ID, just like the Input's, and a Field component, which is on our Roadmap, will put them together for you. Until then, the examples do it with a plain <label>, and the rules hold either way.
<label htmlFor="wifi">Wi-Fi</label>
<Switch id="wifi" checked={on} onChange={(event) => setOn(event.target.checked)} />At a glance
| Aspect | Switch |
|---|---|
| Element | <input type="checkbox">, inside a <span> with the track and the thumb |
| Role | switch, which screen readers announce as on or off |
| Name | Its <label>, linked with for, which names the setting and never changes |
| Keyboard | Space to flip it, and Tab to move on |
| States | Off and on, hover, focus, disabled, read-only and invalid |
| Sizes | xs, sm, md, lg and xl, the same names as the Input and the Button |
| Pattern | APG Switch, on a native checkbox |
| WCAG 2.2 | 1.3.1, 1.4.1, 1.4.11, 2.1.1, 2.3.3, 2.4.7, 2.5.8, 3.2.2, 3.3.1 and 4.1.2, among others |
User Interface (UI)
Let's start with how a switch looks: its parts, its sizes, how it shows each of its states and the tokens it reads.
Anatomy
- Control — The
<input>itself, which holds the state and takes focus. It lies over the track, invisible, and its label names it. - Track — The bar the thumb moves along, an outline while the switch is off and filled while it's on.
- Thumb — The dot that sits at the start of the track while the switch is off, and at the end while it's on.
- Focus ring — An outline around the track, drawn outside it so nothing moves.
The root <span> around them carries the size and takes your classes. The label, and any hint or error message, sit outside it, in the page, and point to the control by its id (see Label every switch). The parts always render in the same order, which you can see in the DOM contract.
Sizes
The size prop scales the track and the thumb together, and md is the default. It takes the same five values as the Input and the Button, and every switch is shorter than the input of the same size, so it fits in a row of form controls of its size.
<label htmlFor="notifications">Notifications</label>
<Switch id="notifications" size="xs" defaultChecked />
{/* …sm, md, lg, xl */}
| Size | Track | Thumb | Hit area |
|---|---|---|---|
xs | 28 × 16px | 10px | 28 × 24px |
sm | 32 × 18px | 12px | 32 × 24px |
md | 36 × 20px | 14px | 36 × 24px |
lg | 44 × 24px | 18px | 44 × 24px |
xl | 52 × 28px | 22px | 52 × 28px |
Sizes are set in rem, and the table shows the values we recommend at the default text size of 16px. The control reaches past a small track to a hit area of at least 24 × 24px (WCAG 2.5.8), so even the smallest switch is easy to hit. On touch screens, the hit area grows to at least 44 × 44px, without the switch looking any bigger. Clicking the label flips the switch too, which makes for a much bigger target.
States
Each state has a look of its own, styled from the <input> that exposes it. That way, what people see and what assistive technologies report can never drift apart, and a switch can't look on without actually being on.
Couldn't turn on price alerts. Try again.
<label htmlFor="wifi">Wi-Fi</label>
<Switch id="wifi" />
<label htmlFor="bluetooth">Bluetooth</label>
<Switch id="bluetooth" defaultChecked />
<label htmlFor="sync">Sync</label>
<Switch id="sync" defaultChecked disabled />
<label htmlFor="sso">Single sign-on</label>
<Switch id="sso" defaultChecked readOnly />
<label htmlFor="alerts">Price alerts</label>
<Switch id="alerts" aria-invalid="true" aria-describedby="alerts-error" />
<p id="alerts-error">Couldn't turn on price alerts. Try again.</p>
| State | Selector | Recommended look |
|---|---|---|
| Off | input:not(:checked) | An outlined track, with the thumb at the start, both at 55% of the text colour |
| On | input:checked | A track filled with --color--primary, with the thumb at the end, in --color--primary-contrast |
| Hover | :hover, inside @media (hover: hover) | While it's off, the track and the thumb at 80% of the text colour, up from 55% |
| Focus | input:focus-visible | A 2px outline, 2px from the track, in --color--focus |
| Disabled | input:disabled | 50% opacity and a not-allowed cursor |
| Read-only | input[aria-readonly="true"] | A dashed border, in the text colour while it's on, unless it's invalid |
| Invalid | input[aria-invalid="true"] | A 2px border in --color--error-text, which an off thumb takes too |
Off and on differ by more than colour. The thumb moves from one end of the track to the other, and the track goes from an outline to a solid fill, so people who can't tell the colours apart can still see which way it's set (WCAG 1.4.1). The track and the thumb read each state from the <input> before them, with :checked, :disabled, aria-readonly and aria-invalid, so they never need a class or a state of their own.
None of the states change the switch's size or position. The thumb only moves inside the track, the invalid border's second pixel is drawn inside it, and the focus ring outside it. You'll find how each state behaves under Every state.
Tokens
The switch reads five theme tokens, which every component shares:
| Token | Used for |
|---|---|
--color--neutral-text | Mixed into the track and the thumb while the switch is off |
--color--primary | The track while the switch is on |
--color--primary-contrast | The thumb while the switch is on |
--color--error-text | The border of an invalid switch |
--color--focus | The focus ring, which is the same for every component |
There's no color prop, so on is always the primary colour. While it's off, the track and the thumb are the text colour at 55%, like the Input's border, since they need 3:1 against the page, and 80% on hover. While it's on, the track needs 3:1 against the page, and the thumb 3:1 against the track.
With our reference tokens, from the Tailwind CSS palette, the switch that's off comes to 3.5:1 on white and 5.0:1 on zinc-900, the track that's on to 6.4:1 and 5.7:1, and the thumb on it to 6.4:1 on both. If you change the tokens, make sure to check the same pairs. You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.
If you need to tune a switch, you can set its own variables, such as --switch--track--width, --switch--track--height or --switch--thumb--offset, from a class. The Switch's styles sit in the components cascade layer, so your class wins without any specificity tricks:
import styles from "./Settings.module.css";
export function Settings() {
return (
<>
<label htmlFor="wifi">Wi-Fi</label>
<Switch id="wifi" className={styles.long} />
</>
);
}
.long {
--switch--track--width: 3rem;
}
User Experience (UX)
Now let's look at how a switch behaves for every person, input and setting. A good switch is accessible, predictable, designed in every state and adaptive.
Accessible
Use the native element
A native <input type="checkbox"> gives you focus, Space to flip it, a label you can click, a state that screen readers announce and form submission, all for free. role="switch" keeps every one of them, and only changes what screen readers say, "on" or "off" rather than "checked" or "not checked", which is how the APG Switch pattern describes it. A <div> gives you none of them, so each one is one more thing to rebuild by hand.
{/* Do: you get focus, Space, a label you can click and a state screen readers announce, for free */}
<label htmlFor="wifi">Wi-Fi</label>
<input id="wifi" type="checkbox" role="switch" />
{/* Don't: there's no role, name, state or keyboard until you rebuild them */}
<div className="switch" onClick={toggle} />
The state is the checkbox's own checked, so there's no aria-checked to keep in sync, and a switch isn't a toggle button, so it doesn't take aria-pressed either. A switch is either on or off, with no mixed state, so indeterminate doesn't apply to it. You'll find why we built it on a checkbox rather than a <button role="switch"> in the FAQ.
Label every switch
The label is also the switch's accessible name. Screen readers read it along with the state, as in "Email me about replies, switch, off", voice control users say it out loud ("click Email me about replies"), and tests and agents use it to find the switch.
At most one email a day
<label htmlFor="replies">Email me about replies</label>
<p id="replies-hint">At most one email a day</p>
<Switch id="replies" aria-describedby="replies-hint" />
- Every switch has a visible
<label>, linked to it withforand the switch'sid(WCAG 1.3.1, 3.3.2). Clicking the label flips the switch too, which makes for a much bigger target. - Write the label so that on means yes, like "Show previews" rather than "Hide previews", so people don't have to work out a double negative.
- A hint, like what the setting changes, goes in the switch's description, which screen readers read after its name. You can do that with
aria-describedby, pointing at the hint'sid. - If you name a switch with
aria-labeloraria-labelledby, make sure the name includes the visible label, ideally at the start (WCAG 2.5.3). - The Switch doesn't make up IDs of its own (see Deterministic), so give it an
idfor its label to point at, and one to each hint and error message that points back at it.
The label names the setting, and stays the same whether the switch is on or off. The switch already says which one it is, so a label that changes, like "Notifications on" and "Notifications off", either repeats the state or, once someone has flipped it, leaves them wondering whether it says what the switch does or what it's done.
{/* Do: the label names the setting, and the switch says whether it's on */}
<label htmlFor="notifications">Notifications</label>
<Switch id="notifications" checked={on} onChange={(event) => setOn(event.target.checked)} />
{/* Don't: the label changes with the state, so it never names the setting */}
<label htmlFor="notifications">{on ? "Notifications on" : "Notifications off"}</label>
<Switch id="notifications" checked={on} onChange={(event) => setOn(event.target.checked)} />
Contrast and size
- The track and the thumb need a contrast of at least 3:1 against what's next to them, in both states (WCAG 1.4.11). While it's off, that's the outline and the thumb against the page, and while it's on, the track against the page and the thumb against the track. The focus ring needs 3:1 as well.
- Off and on differ by more than colour, in where the thumb sits and in whether the track is filled (WCAG 1.4.1).
- The hit area is at least 24 × 24px (WCAG 2.5.8), and at least 44 × 44px on touch screens (WCAG 2.5.5). Clicking the label flips it too, so a row with a label and a switch is easy to hit anywhere.
Show focus
When a switch has focus, its track shows an outline in the same focus colour as every other component (WCAG 2.4.7). Like the Button's, it's at least 2px thick and drawn outside the track, and we recommend 2px for both. Browsers only match :focus-visible on a checkbox when it got focus from the keyboard, so the ring doesn't show after a click, while keyboard users always see where they are.
Predictable
Apply it right away
A switch changes a setting the moment people flip it, just like a light switch. There's no Save button to look for, and nothing lost if they leave the page. That's what tells a switch apart from a checkbox, which only takes effect when its form is sent, so if a setting waits for a Save button, use a Checkbox instead.
import { useState } from "react";
export function DarkMode() {
const [on, setOn] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
async function change(value: boolean) {
// The switch moves at once, and the status says when the change is saved.
setOn(value);
setError("");
setStatus("Saving…");
try {
await api.save({ darkMode: value });
setStatus(value ? "Dark mode is on" : "Dark mode is off");
} catch {
// Back to how it was, with a message that says what happened.
setOn(!value);
setStatus("");
setError("Couldn't change dark mode. Try again.");
}
}
return (
<>
<label htmlFor="dark-mode">Dark mode</label>
<Switch
id="dark-mode"
checked={on}
onChange={(event) => change(event.target.checked)}
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? "dark-mode-error" : undefined}
/>
{error && <p id="dark-mode-error">{error}</p>}
<p role="status">{status}</p>
</>
);
}
api.save stands in for your own request, which saves the setting.
- The switch moves as soon as it's flipped, while the change is saved, so people can see that it worked. Say when it's saved in a status message, which screen readers announce without moving focus (WCAG 4.1.3).
- Keep the switch enabled while the change is saved. Disabling it would take its focus away, and keyboard and screen reader users would lose their place.
- If the change fails, move the switch back, so it never shows a setting that wasn't saved, and say what happened next to it, as under Invalid.
- Flipping a switch changes a setting, and nothing else. It doesn't move focus, open a page or submit a form (WCAG 3.2.2). If it does something people might not expect, like signing them out of their other devices, say so in its hint before they flip it.
Keyboard
| Key | Switch |
|---|---|
Tab, Shift+Tab | Moves focus to and from the switch |
Space | Flips it on or off |
Space comes from the native checkbox, so it works before any script loads. The APG makes Enter optional for a switch, and a native checkbox doesn't flip with it, so we leave it as it is, which keeps every checkbox and switch on the page working the same way.
Every state
Disabled
A disabled switch can't be focused, flipped or submitted. disabled sets the native attribute, so the switch leaves the tab order, screen readers announce it as dimmed or unavailable when they read through the page, and its form leaves it out when it's sent.
We recommend disabling as little as you can, since a disabled switch can't explain why it's disabled, and keyboard users can't even reach it to find out. If people need to see a setting but not change it, make the switch read-only instead. If a setting only works when another one is on, like badges with notifications, say so in its hint. And never disable a switch while it has focus, like while its change is saved, since that takes its focus away.
Read-only
A read-only switch shows a setting that people can't change, like single sign-on that their organisation manages. It stays in the tab order, so people can reach it and hear its state, assistive technologies that support aria-readonly report it as read-only, and its form still sends it. We recommend a dashed border, which tells it apart from a switch you can flip. Off, it keeps its outline colour, and on, the dashes take the text colour, which stands out against the page around the fill, unless it's invalid, where the error colour wins.
HTML doesn't let a checkbox be read-only, since readonly doesn't apply to it, so the Switch does two things itself. It sets aria-readonly="true", which WAI-ARIA allows on a checkbox and so on a switch, and it cancels the click that would flip it. Space and a click on the label both click the <input>, so that one cancelled click covers the keyboard, the pointer and the label, and no change event fires.
Keep in mind that a read-only switch can't say why it's read-only, any more than a disabled one can, so say who can change the setting in its hint, linked with aria-describedby.
Invalid
A switch is invalid when its change didn't go through, like when saving it failed, or when the setting can't be turned on yet, like two-step verification without a phone number. The Switch shows the state, and the page decides when to show it, since only the page knows whether the change worked. You'll find one under States, and the code that sets it under Apply it right away.
- Set
aria-invalid="true"on the switch while its error message shows, and pointaria-describedbyat the message. Screen readers then announce the switch as invalid, and read the message after its name (WCAG 3.3.1). - Say what happened and what to do, as in "Couldn't turn on price alerts. Try again.", rather than "Error". A red border alone can't say any of that, and it means nothing to people who can't tell red apart (WCAG 1.4.1).
- Clear the error as soon as people flip the switch again, since a new try makes the old message out of date.
Adaptive
Pointer, touch and pen
- Hover styles only apply to pointers that can actually hover (
@media (hover: hover)), otherwise a tap would leave the switch looking hovered. - The control lies over the track, and reaches past a small one to a hit area of at least 24 × 24px, and at least 44 × 44px on touch screens, without the switch looking any bigger.
- We recommend leaving out dragging the thumb, which some switches copy from phones. A tap or a click flips it, which works with every pointer, while a drag asks for a movement that some people can't make (WCAG 2.5.7).
Motion
When someone prefers reduced motion (prefers-reduced-motion: reduce), the thumb jumps to the other end of the track rather than slides (WCAG 2.3.3). The slide is short, 150ms in ours, and it only plays when people flip the switch, but it's still motion, and the state is just as clear without it.
More contrast
When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the outline and the thumb of a switch that's off are drawn in the text colour at full strength, rather than at 55%. The switch that's on already passes 3:1, so it stays as it is.
Forced colours
Windows contrast themes replace every colour with the user's own, and drop backgrounds. That would leave the thumb and the filled track invisible, so the Switch keeps their colours with forced-color-adjust: none, and picks system colours for them: CanvasText for the outline and the thumb while it's off, Highlight for the track and HighlightText for the thumb while it's on, and GrayText while it's disabled. Focus is drawn with outline, in Highlight, an invalid switch keeps its 2px border, and a read-only switch keeps its dashes, in CanvasText while it's on.
Colour scheme
The tokens use light-dark(), which follows color-scheme, so the Switch adapts to your light and dark themes on its own. We've checked the contrast in both.
Text size and spacing
The track and the thumb are sized in rem, so they grow with the text at 200% text size (WCAG 1.4.4), and stay in proportion with their label. With wider line, letter and word spacing (WCAG 1.4.12), only the label grows, so let it wrap rather than giving it a fixed width.
Languages and direction
The thumb starts at the inline start of the track and moves to the inline end, set with logical properties, so in right-to-left languages it flips on its own, and on is on the left:
<div dir="rtl" lang="ar">
<label htmlFor="wifi">واي فاي</label>
<Switch id="wifi" defaultChecked />
<label htmlFor="bluetooth">بلوتوث</label>
<Switch id="bluetooth" />
</div>
Remember to translate labels, hints and error messages along with the rest of the page, and to mark any that are in another language with lang, so that screen readers pronounce them correctly (WCAG 3.1.2).
Developer Experience (DX)
Next, let's look at how developers use the Switch. It has one API, with the same names as every other component, it's typed end to end, and it works with your forms however you build them.
import { useState } from "react";
import { Switch } from "@/components/Switch";
export function Notifications() {
const [on, setOn] = useState(true);
return (
<>
<label htmlFor="notifications">Notifications</label>
<Switch id="notifications" checked={on} onChange={(event) => setOn(event.target.checked)} />
</>
);
}
Consistent
Once you've learned the Input's API, you already know most of the Switch's. These names mean the same thing everywhere in Open Components:
| Name | Kind | What it means |
|---|---|---|
size | Prop | xs, sm, md, lg or xl, with the same names everywhere |
disabled | Prop | The component can't be used |
readonly | Prop | People can see and focus the value, but not change it |
checked | Prop | Whether it's on, which you bind the way your framework binds a checkbox's |
We recommend size="md" as the default. The Switch also leaves a few props out, on purpose:
- There's no
label,hintorerror. Each of them is an element of its own, linked by ID, which the Field on our Roadmap will render, so the Switch stays one control that fits any layout, like a settings list with the label at the start of the row. - There's no
color. A switch has no intent of its own, so on is always the primary colour, and its error state comes fromaria-invalid. - There's no
loading. The switch moves as soon as it's flipped, and a status message says when the change is saved, so there's nothing to wait for on the switch itself. - There's no
indeterminate. A switch is on or off, so a group with some options on and some off is better as checkboxes under a parent checkbox, which has a mixed state.
Type-safe
Props and events are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like size="medium", which isn't one of the Switch's sizes.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | How big the switch is |
disabled | boolean | false | Blocks focus, flipping and submission |
readonly | boolean | false | Blocks flipping, while it keeps focus and submission |
checked | boolean | false | Whether it's on, when you bind it. Without it, the switch keeps its own |
| Event | Payload | Description |
|---|---|---|
change | Event | Fires when people flip the switch |
It comes straight from the <input>, like every other native event, such as focus and blur, and a binding to checked listens to it for you. The Switch has no slots, since its label sits outside it.
We also export SwitchProps, which comes in handy for wrappers and data-driven settings:
import type { SwitchProps } from "@/components/Switch";
const settings: (SwitchProps & { name: string; label: string })[] = [
{ name: "wifi", label: "Wi-Fi", checked: true },
{ name: "bluetooth", label: "Bluetooth" },
];
Composable
The Switch renders a few elements, so it splits what you pass between them. Classes and styles go on the root, so a class can set its variables. Everything else lands on the <input>, including id, name, value, ARIA attributes and event listeners. A ref to the Switch reaches the <input> too, since that's what form libraries and focus() need.
Forms
The <input> is a real checkbox, so the Switch works with your forms however you build them. While it's on, its form sends its value, which is on unless you set one, under its name, and while it's off, the form leaves it out, like any checkbox. Form libraries register the <input> itself, through its ref or its attributes and listeners, which is why the Switch puts them all on the <input>.
Keep in mind that a switch still applies right away inside a form. If the form only saves its settings when people press its submit button, use checkboxes instead, as under Apply it right away.
With other components
- The Field, on our Roadmap, will render the label, hint and error message around a Switch, give it an
id, and set itsaria-describedbyandaria-invalidfor you. - The Checkbox is for choices that are sent with a form. It takes the same
sizenames and states as the Switch, and adds an indeterminate state. - A toggle button is for an action that stays pressed, like Bold or Mute in a toolbar, rather than a setting.
- The Input and the Button take the same
sizenames, so a row of form controls can share one.
Controllable
The Switch doesn't keep any state you can't reach. Whether it's on is yours to bind, or to leave to the browser, and every other state is a prop or an attribute that you control:
| State | How you control it |
|---|---|
| Checked | checked, bound to your state, or left to the browser |
| Disabled | disabled |
| Read-only | readonly |
| Invalid | aria-invalid, set while its error message shows (see Invalid) |
| Focus | A ref to its <input>, and focus() |
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.
Checklist
Here's every rule in one place, along with where it comes from and how to check it. A switch meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.
The scope says who meets a rule. Component rules are met by the Switch itself, so they're the ones to check when you build or review one. Usage rules are met by the screens that use it, like giving every switch a label that names its setting, so they're the ones to check when you review a screen. Both rules need the two to work together, like an invalid switch: the Switch looks invalid from aria-invalid, as long as you set it when the error message shows.
The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML, WAI-ARIA and the APG. The rest are Open Components rules, which are our own, like applying a setting as soon as the switch is flipped. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like the 44 × 44px hit area on touch screens, and a rule that asks for more than its criterion says beyond, like using a native checkbox, where WCAG would let you rebuild one by hand.
UI rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
switch/contrast | Must | Component | The track and the thumb have a contrast of 3:1 against what's next to them, on and off, and so does the focus ring | WCAG 1.4.11 (AA) | Contrast checker |
switch/more-than-colour | Must | Component | On and off differ by more than colour: the thumb moves to the other end, and the track fills | WCAG 1.4.1 (A) | Review |
switch/focus-visible | Must | Component | Focus shows an outline at least 2px thick around the track, outside it | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard |
switch/target-size | Must | Component | The hit area is at least 24 × 24px, and at least 44 × 44px on touch screens | WCAG 2.5.8 (AA), 2.5.5 (AAA) | Emulation |
switch/resizable | Must | Component | The track and the thumb are sized in rem, so they grow with the text | Open Components, beyond WCAG 1.4.4 (AA) | 200% zoom |
switch/stable-size | Must | Component | No state changes the switch's size or position, and the thumb only moves inside the track | Open Components | Visual regression test |
switch/state-selectors | Should | Component | States are styled from the <input> that exposes them, with :checked, :disabled, aria-readonly and aria-invalid | Open Components | Review |
switch/hover-capable | Should | Component | Hover styles only apply inside @media (hover: hover) | Open Components | Review |
switch/reduced-motion | Must | Component | The thumb doesn't slide when prefers-reduced-motion: reduce is set | WCAG 2.3.3 (AAA) | Emulation |
switch/forced-colors | Must | Component | The track, the thumb and the on, focus, disabled and read-only states stay visible in forced colours mode | Open Components | Emulation |
switch/more-contrast | Should | Component | The track and the thumb are drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
UX rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
switch/native-element | Must | Component | It's a native <input type="checkbox"> with role="switch", and never a <div> | APG, Open Components, beyond WCAG 2.1.1, 4.1.2 (A) | Unit test |
switch/checked-state | Must | Component | Its state is the checkbox's own checked, announced as on or off, with no pressed or mixed state | WAI-ARIA, APG | Unit test |
switch/visible-label | Must | Usage | Every switch has a visible <label>, linked with for | WCAG 1.3.1, 3.3.2, 4.1.2 (A) | axe label |
switch/label-in-name | Must | Usage | An aria-label or aria-labelledby includes the visible label, ideally at the start | WCAG 2.5.3 (A) | axe label-content-name-mismatch |
switch/stable-label | Must | Usage | The label names the setting and stays the same whether the switch is on or off | APG | Review |
switch/described | Should | Usage | Hints, like what the setting changes, are linked with aria-describedby | WCAG 1.3.1 (A) | Screen reader |
switch/keyboard | Must | Component | Tab reaches the switch, and Space and a click on its label flip it | WCAG 2.1.1 (A), APG | Unit test |
switch/applies-immediately | Must | Usage | Flipping it applies its setting right away, with no Save button, and a setting that waits for one is a checkbox instead | Open Components | Review |
switch/announce-saved | Should | Usage | While its change is saved, it keeps its new state and stays enabled, and a status message says when it's saved | WCAG 4.1.3 (AA), Open Components | Screen reader |
switch/no-context-change | Must | Usage | Flipping it never moves focus, opens a page or submits a form, unless its hint says so first | WCAG 3.2.2 (A) | Review |
switch/error-message | Must | Usage | An invalid switch has an error message, in text and linked with aria-describedby, that says what happened and what to do | WCAG 1.4.1, 3.3.1 (A) | Screen reader |
switch/invalid-state | Must | Both | A switch sets aria-invalid="true" while its error message shows, and only looks invalid when it does | WCAG 4.1.2 (A), Open Components | Unit test |
switch/revert-on-failure | Should | Usage | When its change fails, it goes back to how it was, and its error message says so | Open Components | Review |
switch/disabled-inert | Must | Component | A disabled switch can't be focused, flipped or submitted | HTML | Unit test |
switch/read-only | Must | Component | A read-only switch can be focused and is sent with its form, but doesn't flip, and sets aria-readonly="true" | WAI-ARIA | Unit test |
switch/read-only-values | Should | Usage | A setting people need to see but can't change is read-only rather than disabled, with a hint that says who can change it | Open Components | Review |
switch/keep-focus | Must | Usage | A switch is never disabled while it has focus, like while its change is saved | Open Components | Keyboard |
switch/mirrors-in-rtl | Should | Component | The thumb moves from the inline start to the inline end, so on is on the left in right-to-left layouts | Open Components | Review |
DX rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
switch/shared-vocabulary | Must | Component | It uses the shared names: size, disabled, readonly and checked | Open Components | Review |
switch/typed | Must | Component | Props and events are typed, with unions rather than string | Open Components | Type check |
switch/control-attributes | Must | Component | Attributes, listeners and refs land on the <input>, while classes and styles land on the root | Open Components | Unit test |
switch/form-control | Must | Component | Its form sends its value under its name while it's on, whether checked is bound or not | HTML | Unit test |
switch/controllable | Should | Component | Whether it's on can be bound or left to the browser, and every other state is a prop or an attribute | Open Components | Unit test |
AX rules
| 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 |
Reference implementation
Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It reads the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme too.
The tests are in Switch.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.
<script setup lang="ts">
import { onMounted, useAttrs, useTemplateRef } from "vue";
export interface SwitchProps {
size?: "xs" | "sm" | "md" | "lg" | "xl";
disabled?: boolean;
/** Shows a setting people can see and focus, but not change. A checkbox has no `readonly` of its own, so the Switch blocks the flip and sets `aria-readonly`. */
readonly?: boolean;
}
// Attributes go on the <input> rather than on the root, so they're declared below.
defineOptions({ inheritAttrs: false });
const { size = "md", disabled = false, readonly = false } = defineProps<SwitchProps>();
/** Whether it's on, which `v-model:checked` binds. Without it, the switch keeps its own state, and its form submits it while it's on. */
const checked = defineModel<boolean>("checked", { default: false });
const attrs = useAttrs();
// Classes and styles go on the root, which sizes the switch, so a class can set its
// variables. Everything else goes on the <input>: `id`, `name`, `value`, ARIA and
// listeners. Attrs aren't reactive, so these run on every render.
const onRoot = (key: string) => key === "class" || key === "style";
const rootAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => onRoot(key)));
const controlAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => !onRoot(key)));
const input = useTemplateRef<HTMLInputElement>("input");
function onChange(event: Event) {
checked.value = (event.target as HTMLInputElement).checked;
}
// Space and a click on the label both click the <input>, so cancelling the click keeps a
// read-only switch as it is, without a change event. It still takes focus, and its form
// still sends it, unlike a disabled one.
function onClick(event: MouseEvent) {
if (readonly) event.preventDefault();
}
// A ref to the Switch gets you the <input>, through `input`.
defineExpose({ input });
onMounted(() => {
if (import.meta.env.DEV) {
const el = input.value;
const named = el?.labels?.length || el?.hasAttribute("aria-label") || el?.hasAttribute("aria-labelledby");
if (el && !named) {
console.warn("[Switch] has no accessible name: give it a <label for> that names the setting.", el);
}
}
});
</script>
<template>
<span class="switch" v-bind="rootAttrs()" :data-size="size">
<!-- A native checkbox, so Space, the label, forms and autofill work as they always do.
`role="switch"` makes screen readers announce it as on or off rather than checked. -->
<input
ref="input"
data-slot="control"
:disabled="disabled"
v-bind="controlAttrs()"
type="checkbox"
role="switch"
:aria-readonly="readonly ? 'true' : undefined"
:checked="checked"
@click="onClick"
@change="onChange"
/>
<!-- The track and thumb only draw the state, which the <input> already exposes. -->
<span data-slot="track" aria-hidden="true"><span data-slot="thumb" /></span>
</span>
</template>
<style scoped>
/* The same layer order as Tailwind CSS. The switch's styles go in `components`,
so they win over the resets in `base`, and give way to utilities and to any
style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;
@layer components {
.switch {
/* Set per size. */
--switch--track--width: 2.25rem;
--switch--track--height: 1.25rem;
/* The gap between the thumb and the track's border. */
--switch--thumb--offset: 0.125rem;
/* The thumb fills the track's height, inside its 1px border, less the offset on each side. */
--switch--thumb--size: calc(var(--switch--track--height) - 2px - 2 * var(--switch--thumb--offset));
/* Off, the track is an outline and the thumb a dot, both in the text colour at 55%,
which has 3:1 against the page, like the Input's border. */
--switch--track--border-color: color-mix(in srgb, currentColor 55%, transparent);
--switch--track--background-color: transparent;
--switch--thumb--background-color: var(--switch--track--border-color);
position: relative;
display: inline-flex;
flex-shrink: 0;
vertical-align: middle;
color: var(--color--neutral-text);
}
/* Sizes */
.switch[data-size="xs"] {
--switch--track--width: 1.75rem;
--switch--track--height: 1rem;
}
.switch[data-size="sm"] {
--switch--track--width: 2rem;
--switch--track--height: 1.125rem;
}
.switch[data-size="lg"] {
--switch--track--width: 2.75rem;
--switch--track--height: 1.5rem;
}
.switch[data-size="xl"] {
--switch--track--width: 3.25rem;
--switch--track--height: 1.75rem;
}
/* The <input> lies on top of the track, invisible, so every press lands on the real
control. It reaches past a small track to a hit area at least 24 × 24px. */
.switch > [data-slot="control"] {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
width: max(100%, 1.5rem);
height: max(100%, 1.5rem);
margin: 0;
translate: -50% -50%;
opacity: 0;
cursor: pointer;
}
.switch > [data-slot="control"]:disabled {
cursor: not-allowed;
}
/* Parts */
.switch > [data-slot="track"] {
position: relative;
box-sizing: border-box;
width: var(--switch--track--width);
height: var(--switch--track--height);
border: 1px solid var(--switch--track--border-color);
border-radius: 999px;
background-color: var(--switch--track--background-color);
}
/* The thumb starts at the inline start and moves to the inline end, so it flips on its
own in right-to-left languages, where on is on the left. */
.switch > [data-slot="track"] > [data-slot="thumb"] {
position: absolute;
top: 50%;
inset-inline-start: var(--switch--thumb--offset);
width: var(--switch--thumb--size);
height: var(--switch--thumb--size);
border-radius: 999px;
background-color: var(--switch--thumb--background-color);
translate: 0 -50%;
transition: inset-inline-start 150ms ease-out;
}
/* States. Each one reads the <input>, so what people see and what assistive technologies
report can't drift apart. Like every variable, they're set on the root, where a class
of yours can set them too. The thumb's colour follows the track's border unless a state
sets its own. */
@media (hover: hover) {
.switch:hover:where(:has(> [data-slot="control"]:not(:checked, :disabled, [aria-readonly="true"]))) {
--switch--track--border-color: color-mix(in srgb, currentColor 80%, transparent);
}
}
.switch > [data-slot="control"]:focus-visible + [data-slot="track"] {
outline: 2px solid var(--color--focus);
outline-offset: 2px;
}
/* On, the track fills and the thumb moves to the end, so on and off differ by more
than colour. */
.switch:has(> [data-slot="control"]:checked) {
--switch--track--border-color: var(--color--primary);
--switch--track--background-color: var(--color--primary);
--switch--thumb--background-color: var(--color--primary-contrast);
}
.switch > [data-slot="control"]:checked + [data-slot="track"] > [data-slot="thumb"] {
inset-inline-start: calc(100% - var(--switch--thumb--size) - var(--switch--thumb--offset));
}
.switch:has(> [data-slot="control"][aria-invalid="true"]) {
--switch--track--border-color: var(--color--error-text);
}
.switch > [data-slot="control"][aria-invalid="true"] + [data-slot="track"] {
/* A second pixel of border, drawn inside, so the switch doesn't grow. */
box-shadow: inset 0 0 0 1px var(--switch--track--border-color);
}
.switch > [data-slot="control"]:disabled + [data-slot="track"] {
opacity: 0.5;
}
/* Read-only, the track's border is dashed, like the Input's, so it reads as something you
can't change. On, the dashes take the text colour, which stands out against the page
around the fill, unless the switch is invalid, where the error colour wins. */
.switch > [data-slot="control"][aria-readonly="true"] {
cursor: default;
}
.switch > [data-slot="control"][aria-readonly="true"] + [data-slot="track"] {
border-style: dashed;
}
.switch:has(> [data-slot="control"][aria-readonly="true"]:checked:not([aria-invalid="true"], :disabled)) {
--switch--track--border-color: currentColor;
}
/* On touch screens, the hit area grows to at least 44 × 44px, without the switch
looking any bigger. */
@media (any-pointer: coarse) {
.switch > [data-slot="control"] {
width: max(100%, 2.75rem);
height: max(100%, 2.75rem);
}
}
/* The thumb jumps rather than slides when people ask for less motion. */
@media (prefers-reduced-motion: reduce) {
.switch > [data-slot="track"] > [data-slot="thumb"] {
transition: none;
}
}
/* With more contrast, the track and thumb are drawn in the text colour at full strength,
hovered or not. */
@media (prefers-contrast: more) {
.switch,
.switch:hover {
--switch--track--border-color: currentColor;
}
}
/* Forced colors mode would paint the track and thumb's backgrounds over with the page's,
hiding the thumb and the on state, so they keep their colours and use system ones. */
@media (forced-colors: active) {
.switch,
.switch:hover {
--switch--track--border-color: CanvasText;
--switch--track--background-color: Canvas;
--switch--thumb--background-color: CanvasText;
}
.switch > [data-slot="track"] {
forced-color-adjust: none;
}
.switch:has(> [data-slot="control"]:checked) {
--switch--track--border-color: Highlight;
--switch--track--background-color: Highlight;
--switch--thumb--background-color: HighlightText;
}
/* On, the read-only dashes and the invalid border are drawn in CanvasText, so they show
around the Highlight fill. The read-only selector matches the one above, so it wins.
The invalid border keeps its second pixel, which tells it apart without colour. */
.switch:has(> [data-slot="control"][aria-readonly="true"]:checked:not([aria-invalid="true"], :disabled)),
.switch:has(> [data-slot="control"][aria-invalid="true"]) {
--switch--track--border-color: CanvasText;
}
.switch > [data-slot="control"]:focus-visible + [data-slot="track"] {
outline-color: Highlight;
}
.switch:has(> [data-slot="control"]:disabled) {
--switch--track--border-color: GrayText;
--switch--track--background-color: Canvas;
--switch--thumb--background-color: GrayText;
}
.switch > [data-slot="control"]:disabled + [data-slot="track"] {
opacity: 1;
}
}
}
</style>
import { render, screen } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { h, nextTick, ref, type ComponentPublicInstance } from "vue";
import Switch from "./Switch.vue";
const label = (id: string, text: string) => h("label", { for: id }, text);
describe("Switch", () => {
it("is a native checkbox with the switch role, found by its role and its label", () => {
render({ render: () => [label("wifi", "Wi-Fi"), h(Switch, { id: "wifi" })] });
const control = screen.getByRole("switch", { name: "Wi-Fi" });
expect(control.tagName).toBe("INPUT");
expect(control).toHaveAttribute("type", "checkbox");
});
it("says whether it's on with checked, never aria-pressed, and keeps its name in both states", async () => {
const user = userEvent.setup();
render({ render: () => [label("wifi", "Wi-Fi"), h(Switch, { id: "wifi" })] });
const control = screen.getByRole("switch", { name: "Wi-Fi", checked: false });
await user.click(control);
expect(screen.getByRole("switch", { name: "Wi-Fi", checked: true })).toBe(control);
expect(control).not.toHaveAttribute("aria-pressed");
expect(control).not.toHaveAttribute("aria-checked");
});
it("toggles with Space and with a click on its label, firing change each time", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render({ render: () => [label("wifi", "Wi-Fi"), h(Switch, { id: "wifi", onChange })] });
const control = screen.getByRole("switch", { name: "Wi-Fi" });
await user.tab();
expect(control).toHaveFocus();
await user.keyboard(" ");
expect(control).toBeChecked();
await user.click(screen.getByText("Wi-Fi"));
expect(control).not.toBeChecked();
expect(onChange).toHaveBeenCalledTimes(2);
});
it("binds checked with v-model, or keeps its own state without it", async () => {
const user = userEvent.setup();
const on = ref(true);
render({
render: () => [
h(Switch, {
"aria-label": "Bound",
checked: on.value,
"onUpdate:checked": (value: boolean) => (on.value = value),
}),
h(Switch, { "aria-label": "Unbound" }),
],
});
const bound = screen.getByRole("switch", { name: "Bound" });
expect(bound).toBeChecked();
await user.click(bound);
expect(on.value).toBe(false);
on.value = true;
await nextTick();
expect(bound).toBeChecked();
const unbound = screen.getByRole("switch", { name: "Unbound" });
await user.click(unbound);
expect(unbound).toBeChecked();
});
it("is submitted with its form, by its name and value, only while it's on, bound or not", async () => {
const user = userEvent.setup();
const tips = ref(true);
render({
render: () =>
h("form", [
label("updates", "Product updates"),
h(Switch, { id: "updates", name: "updates" }),
label("tips", "Tips"),
h(Switch, {
id: "tips",
name: "tips",
value: "weekly",
checked: tips.value,
"onUpdate:checked": (value: boolean) => (tips.value = value),
}),
]),
});
const updates = screen.getByRole("switch", { name: "Product updates" });
const form = updates.closest("form")!;
expect(new FormData(form).has("updates")).toBe(false);
expect(new FormData(form).get("tips")).toBe("weekly");
await user.click(updates);
await user.click(screen.getByRole("switch", { name: "Tips" }));
expect(new FormData(form).get("updates")).toBe("on");
expect(new FormData(form).has("tips")).toBe(false);
});
it("puts attributes, listeners and refs on the input, and classes and styles on its root", async () => {
const user = userEvent.setup();
const onFocus = vi.fn();
const instance = ref<ComponentPublicInstance<{ input: HTMLInputElement }>>();
render({
render: () => [
label("wifi", "Wi-Fi"),
h(Switch, { ref: instance, id: "wifi", class: "wifi", style: "--switch--track--width: 3rem", name: "wifi", value: "enabled", onFocus }),
],
});
const control = screen.getByRole("switch", { name: "Wi-Fi" });
await user.tab();
expect(control).toHaveAttribute("name", "wifi");
expect(control).toHaveAttribute("value", "enabled");
expect(control).not.toHaveClass("wifi");
expect(control.parentElement).toHaveClass("switch", "wifi");
expect(control).not.toHaveAttribute("style");
expect(control.parentElement).toHaveAttribute("style", "--switch--track--width: 3rem;");
expect(onFocus).toHaveBeenCalled();
expect(instance.value?.input).toBe(control);
});
it("can't be focused or toggled while disabled, and its form leaves it out", async () => {
const user = userEvent.setup();
render({
render: () =>
h("form", [label("sync", "Sync"), h(Switch, { id: "sync", name: "sync", checked: true, disabled: true })]),
});
const control = screen.getByRole("switch", { name: "Sync" });
await user.tab();
await user.click(control);
expect(control).toBeDisabled();
expect(control).not.toHaveFocus();
expect(control).toBeChecked();
expect(new FormData(control.closest("form")!).has("sync")).toBe(false);
});
it("can be focused but not flipped while read-only, says so, and its form still sends it", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render({
render: () =>
h("form", [
label("sso", "Single sign-on"),
h(Switch, { id: "sso", name: "sso", checked: true, readonly: true, onChange }),
]),
});
const control = screen.getByRole("switch", { name: "Single sign-on" });
expect(control).toHaveAttribute("aria-readonly", "true");
await user.tab();
expect(control).toHaveFocus();
await user.keyboard(" ");
await user.click(control);
await user.click(screen.getByText("Single sign-on"));
expect(control).toBeChecked();
expect(onChange).not.toHaveBeenCalled();
expect(new FormData(control.closest("form")!).get("sso")).toBe("on");
});
it("passes aria-invalid and its error message to the input", () => {
render({
render: () => [
label("alerts", "Price alerts"),
h(Switch, { id: "alerts", "aria-invalid": "true", "aria-describedby": "alerts-error" }),
h("p", { id: "alerts-error" }, "Couldn't turn on price alerts. Try again."),
],
});
const control = screen.getByRole("switch", { name: "Price alerts" });
expect(control).toHaveAttribute("aria-invalid", "true");
expect(control).toHaveAccessibleDescription("Couldn't turn on price alerts. Try again.");
});
it("mirrors its size as a data attribute", () => {
const { container } = render(Switch, { attrs: { "aria-label": "Wi-Fi" }, props: { size: "lg" } });
expect(container.firstElementChild).toHaveAttribute("data-size", "lg");
});
it("renders the same markup every time, with its parts in a fixed order, across states", async () => {
const options = { attrs: { "aria-label": "Wi-Fi" } };
const first = render(Switch, options).container.innerHTML;
const { container, rerender } = render(Switch, options);
expect(container.innerHTML).toBe(first);
const root = container.firstElementChild!;
const parts = () => [...root.querySelectorAll("[data-slot]")].map((part) => part.getAttribute("data-slot"));
expect(parts()).toEqual(["control", "track", "thumb"]);
expect(root.querySelector('[data-slot="track"]')).toHaveAttribute("aria-hidden", "true");
const input = root.querySelector("input");
await rerender({ checked: true, disabled: true });
expect(container.firstElementChild).toBe(root);
expect(root.querySelector("input")).toBe(input);
expect(parts()).toEqual(["control", "track", "thumb"]);
});
it("warns in development when it has no label", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
render(Switch);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("no accessible name"), expect.anything());
warn.mockClear();
render({ render: () => [label("wifi", "Wi-Fi"), h(Switch, { id: "wifi" })] });
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});
FAQ
Switch or something else?
A switch turns a setting on or off, right away. For anything else, another component will do a better job:
| When people need to… | Use |
|---|---|
| Turn a setting on or off, with an effect they see right away | Switch |
| Pick options that are sent with a form, like agreeing to terms | Checkbox |
| Turn an action on and off in a toolbar, like Bold or Mute | Toggle button |
| Pick one of a few settings, like Light, Dark or System | Radio Group |
The Checkbox takes the same size names and states as the Switch, and adds an indeterminate state, but its <label> wraps the box, where the Switch's is linked with for. The Radio Group is on our Roadmap, and it'll share the Switch's sizes, states and rules for labels.
Space flips it, its label flips it, its form sends it and the browser keeps its state, all before your JavaScript loads. A <button role="switch"> needs a script to flip aria-checked, and a hidden input to be sent with a form. What the button adds is Enter, which the APG makes optional.aria-hidden="true", since the switch already announces whether it's on, and it would only be read twice.required that's checked when the form is sent.Space, and its form sends it. Only applying the setting right away needs a script, so for a settings page that should work without one, put the switches in a form with a submit button as well. A read-only switch is the exception: the script is what stops it from flipping, so until it loads, people can flip it and its form sends the new value, which your server shouldn't trust.Sources
- WAI-ARIA Authoring Practices Guide: Switch pattern, and its checkbox-based example, for the role, the states and the keyboard
- WAI-ARIA 1.2: the switch role, for a state that's on or off, with no mixed value, and
aria-readonly, which a checkbox takes and a switch inherits - HTML Standard: the checkbox state and the readonly attribute, which doesn't apply to checkboxes, so the Switch uses
aria-readonly - Web Content Accessibility Guidelines (WCAG) 2.2
- Understanding SC 1.4.11: Non-text Contrast, for the track and the thumb
- Understanding SC 3.2.2: On Input, for what flipping a switch may change
- Sara Soueidan: On designing and building toggle switches, for labels, states and the checkbox
- Inclusive Components: Toggle Buttons, for toggle buttons against switches
- Primer: Toggle switch, for applying a setting right away and showing that it's saving
- Apple Human Interface Guidelines: Toggles, for switches in settings lists
Spinner
How to build a spinner that shows something is busy, without getting in the way of screen readers, reduced motion or the layout around it.
Textarea
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.