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;
}
UI Checklist
| 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 |