Components

Switch

How to build a switch that turns a setting on or off right away, that everyone can see, label and flip, and that agents can check.

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

AspectSwitch
Element<input type="checkbox">, inside a <span> with the track and the thumb
Roleswitch, which screen readers announce as on or off
NameIts <label>, linked with for, which names the setting and never changes
KeyboardSpace to flip it, and Tab to move on
StatesOff and on, hover, focus, disabled, read-only and invalid
Sizesxs, sm, md, lg and xl, the same names as the Input and the Button
PatternAPG Switch, on a native checkbox
WCAG 2.21.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

  1. Control — The <input> itself, which holds the state and takes focus. It lies over the track, invisible, and its label names it.
  2. Track — The bar the thumb moves along, an outline while the switch is off and filled while it's on.
  3. Thumb — The dot that sits at the start of the track while the switch is off, and at the end while it's on.
  4. 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 */}
SizeTrackThumbHit area
xs28 × 16px10px28 × 24px
sm32 × 18px12px32 × 24px
md36 × 20px14px36 × 24px
lg44 × 24px18px44 × 24px
xl52 × 28px22px52 × 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.

Off (try hovering, clicking or tabbing to it)
On
Disabled
Read-only (try flipping it)

Couldn't turn on price alerts. Try again.

Invalid
<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>
StateSelectorRecommended look
Offinput:not(:checked)An outlined track, with the thumb at the start, both at 55% of the text colour
Oninput:checkedA 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%
Focusinput:focus-visibleA 2px outline, 2px from the track, in --color--focus
Disabledinput:disabled50% opacity and a not-allowed cursor
Read-onlyinput[aria-readonly="true"]A dashed border, in the text colour while it's on, unless it's invalid
Invalidinput[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:

TokenUsed for
--color--neutral-textMixed into the track and the thumb while the switch is off
--color--primaryThe track while the switch is on
--color--primary-contrastThe thumb while the switch is on
--color--error-textThe border of an invalid switch
--color--focusThe 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:

Settings.tsx
import styles from "./Settings.module.css";

export function Settings() {
  return (
    <>
      <label htmlFor="wifi">Wi-Fi</label>
      <Switch id="wifi" className={styles.long} />
    </>
  );
}
Settings.module.css
.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 with for and the switch's id (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's id.
  • If you name a switch with aria-label or aria-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 id for 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

KeySwitch
Tab, Shift+TabMoves focus to and from the switch
SpaceFlips 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 point aria-describedby at 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:

NameKindWhat it means
sizePropxs, sm, md, lg or xl, with the same names everywhere
disabledPropThe component can't be used
readonlyPropPeople can see and focus the value, but not change it
checkedPropWhether 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, hint or error. 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 from aria-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.

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"How big the switch is
disabledbooleanfalseBlocks focus, flipping and submission
readonlybooleanfalseBlocks flipping, while it keeps focus and submission
checkedbooleanfalseWhether it's on, when you bind it. Without it, the switch keeps its own
EventPayloadDescription
changeEventFires 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 its aria-describedby and aria-invalid for you.
  • The Checkbox is for choices that are sent with a form. It takes the same size names 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 size names, 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:

StateHow you control it
Checkedchecked, bound to your state, or left to the browser
Disableddisabled
Read-onlyreadonly
Invalidaria-invalid, set while its error message shows (see Invalid)
FocusA 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. 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.

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

RuleLevelScopeRequirementBasisCheck
switch/contrastMustComponentThe track and the thumb have a contrast of 3:1 against what's next to them, on and off, and so does the focus ringWCAG 1.4.11 (AA)Contrast checker
switch/more-than-colourMustComponentOn and off differ by more than colour: the thumb moves to the other end, and the track fillsWCAG 1.4.1 (A)Review
switch/focus-visibleMustComponentFocus shows an outline at least 2px thick around the track, outside itWCAG 2.4.7 (AA), 2.4.13 (AAA)Keyboard
switch/target-sizeMustComponentThe hit area is at least 24 × 24px, and at least 44 × 44px on touch screensWCAG 2.5.8 (AA), 2.5.5 (AAA)Emulation
switch/resizableMustComponentThe track and the thumb are sized in rem, so they grow with the textOpen Components, beyond WCAG 1.4.4 (AA)200% zoom
switch/stable-sizeMustComponentNo state changes the switch's size or position, and the thumb only moves inside the trackOpen ComponentsVisual regression test
switch/state-selectorsShouldComponentStates are styled from the <input> that exposes them, with :checked, :disabled, aria-readonly and aria-invalidOpen ComponentsReview
switch/hover-capableShouldComponentHover styles only apply inside @media (hover: hover)Open ComponentsReview
switch/reduced-motionMustComponentThe thumb doesn't slide when prefers-reduced-motion: reduce is setWCAG 2.3.3 (AAA)Emulation
switch/forced-colorsMustComponentThe track, the thumb and the on, focus, disabled and read-only states stay visible in forced colours modeOpen ComponentsEmulation
switch/more-contrastShouldComponentThe track and the thumb are drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation

UX rules

RuleLevelScopeRequirementBasisCheck
switch/native-elementMustComponentIt'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-stateMustComponentIts state is the checkbox's own checked, announced as on or off, with no pressed or mixed stateWAI-ARIA, APGUnit test
switch/visible-labelMustUsageEvery switch has a visible <label>, linked with forWCAG 1.3.1, 3.3.2, 4.1.2 (A)axe label
switch/label-in-nameMustUsageAn aria-label or aria-labelledby includes the visible label, ideally at the startWCAG 2.5.3 (A)axe label-content-name-mismatch
switch/stable-labelMustUsageThe label names the setting and stays the same whether the switch is on or offAPGReview
switch/describedShouldUsageHints, like what the setting changes, are linked with aria-describedbyWCAG 1.3.1 (A)Screen reader
switch/keyboardMustComponentTab reaches the switch, and Space and a click on its label flip itWCAG 2.1.1 (A), APGUnit test
switch/applies-immediatelyMustUsageFlipping it applies its setting right away, with no Save button, and a setting that waits for one is a checkbox insteadOpen ComponentsReview
switch/announce-savedShouldUsageWhile its change is saved, it keeps its new state and stays enabled, and a status message says when it's savedWCAG 4.1.3 (AA), Open ComponentsScreen reader
switch/no-context-changeMustUsageFlipping it never moves focus, opens a page or submits a form, unless its hint says so firstWCAG 3.2.2 (A)Review
switch/error-messageMustUsageAn invalid switch has an error message, in text and linked with aria-describedby, that says what happened and what to doWCAG 1.4.1, 3.3.1 (A)Screen reader
switch/invalid-stateMustBothA switch sets aria-invalid="true" while its error message shows, and only looks invalid when it doesWCAG 4.1.2 (A), Open ComponentsUnit test
switch/revert-on-failureShouldUsageWhen its change fails, it goes back to how it was, and its error message says soOpen ComponentsReview
switch/disabled-inertMustComponentA disabled switch can't be focused, flipped or submittedHTMLUnit test
switch/read-onlyMustComponentA read-only switch can be focused and is sent with its form, but doesn't flip, and sets aria-readonly="true"WAI-ARIAUnit test
switch/read-only-valuesShouldUsageA setting people need to see but can't change is read-only rather than disabled, with a hint that says who can change itOpen ComponentsReview
switch/keep-focusMustUsageA switch is never disabled while it has focus, like while its change is savedOpen ComponentsKeyboard
switch/mirrors-in-rtlShouldComponentThe thumb moves from the inline start to the inline end, so on is on the left in right-to-left layoutsOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
switch/shared-vocabularyMustComponentIt uses the shared names: size, disabled, readonly and checkedOpen ComponentsReview
switch/typedMustComponentProps and events are typed, with unions rather than stringOpen ComponentsType check
switch/control-attributesMustComponentAttributes, listeners and refs land on the <input>, while classes and styles land on the rootOpen ComponentsUnit test
switch/form-controlMustComponentIts form sends its value under its name while it's on, whether checked is bound or notHTMLUnit test
switch/controllableShouldComponentWhether it's on can be bound or left to the browser, and every other state is a prop or an attributeOpen ComponentsUnit test

AX rules

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

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>

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 awaySwitch
Pick options that are sent with a form, like agreeing to termsCheckbox
Turn an action on and off in a toolbar, like Bold or MuteToggle button
Pick one of a few settings, like Light, Dark or SystemRadio 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.

Sources

Copyright © 2026