Switch
A binary setting that saves the instant it is flipped. If it needs a Save button, it is a checkbox wearing a costume.
Also called Toggle, On/Off, Toggle Switch — in this system all of them are Switch.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
A settings list
Control on the right, one shared right edge, dividers rather than gaps. The header states explicitly that changes save immediately — that sentence removes an entire class of support ticket.
Switch or checkbox?
The only question that matters: is there a Save button?
Optimistic with rollback
The switch moves immediately, then reverts with an explanation if the request fails. Try the failing one — it flips, waits, and comes back with a reason.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Label and description on the left, control on the right. The whole row is a settings row; only the switch is the control.
- Track38 × 22px
Roughly a 1.7:1 ratio. Narrower and the knob has nowhere to travel; wider and it stops reading as a switch and starts reading as a slider.
- Knob18px, 2px inset
Always white, in both themes, with a small shadow. It is a physical object sitting in a track — that metaphor is the whole affordance.
- Travel16px
Track width minus knob width minus the insets. Animated on transform so it never triggers layout.
- Track colourborder-strong → accent
Three channels carry the state, not two: track colour, knob position, and the check inside the handle. Colour and position alone both have a reader they fail — see Accessibility.
- Duration180ms emphasized
Long enough to read as movement, short enough that rapid toggling never queues up. A spring here would overshoot the track edge.
- Row alignmentControl right, 12px gap
A shared right edge across every row is what makes a settings list scannable in a single pass down the column.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-border-strong | — | Off track |
| --ds-fg-disabled | — | Off track, hovered |
| --ds-accent | — | On track |
| #ffffff | — | Knob — the same in both themes |
| --ds-focus-ring | — | Focus outline |
Spacing
| Token | Value | Used for |
|---|---|---|
| track | Medium switch | |
| gap | Control to label in a settings row |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Track and knob |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e1 | — | Knob elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| --ease-emphasized | Knob travel and track colour |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Label gap | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Small | 18px | — | 10px | 32px | 44px (row) | Dense settings tables and inline toolbar toggles. |
| Medium | 22px | — | 12px | 38px | 44px (row) | The default. Every settings list. |
| Row | 56px | 14px 20px | — | — | — | A settings row with a label and a one-line description. |
Changes save immediately.
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The off track must reach 3:1 against the surface, or the control is invisible until someone hovers it.
- The on track must be distinguishable from the off track in greyscale — accent against a neutral grey is a narrower luminance gap than it looks, and deuteranopia closes it further.
- That is why the handle carries a check when on. Colour is one channel and knob position is the other, and position only reads as "on" when there is a second switch nearby to compare it against — a lone switch in a settings row has nothing to be compared to. The glyph is the channel that needs neither. Material offers the same thing as thumbIcon for the same reason.
- The knob stays white in both themes. A knob that matches the surface disappears against the off track.
Keyboard
| Tab | Moves to the switch. Every switch is individually tabbable. |
| Space | Toggles. This is the primary binding. |
| Enter | Also toggles, because role="switch" is a button — unlike a native checkbox. |
Screen readers
- Announced as "Require two-factor authentication, switch, on". The word "on" is why role="switch" is worth using over a styled checkbox.
- If a toggle triggers an asynchronous save, announce the outcome. Silence after a failure means the user believes the change took effect.
- Never rely on the visual position of the knob. aria-checked is the only thing assistive tech reads.
Focus & touch
- The ring is on the switch itself, not the whole row. A ring around the entire settings row makes it ambiguous which of several stacked switches has focus.
- The switch is 38 × 22, so the row provides the 44px target. In a list, make the whole row activate the switch — but only if nothing else in the row is interactive.
| Attribute | Applied to | Notes |
|---|---|---|
| role="switch" | The control | Announces as "on/off" rather than "checked/unchecked", which is the correct mental model for an instant setting. |
| aria-checked | The control | true or false. Never "mixed" — a switch has no indeterminate state. |
| aria-labelledby | The control | Points at the visible label. The label is not a <label> because the control is a button, not an input. |
| aria-describedby | The control | Points at the description line. |
| aria-busy | The control | While an optimistic update is in flight, so assistive tech knows the state is provisional. |
| aria-live="polite" | A status region | Announces a rollback: "Could not save. Two-factor authentication is still off." |
Example usage
1import { Switch } from '@/ui/Toggle'23// Settings row: control on the right4<Switch5 checked={twoFactor}6 onCheckedChange={setTwoFactor}7 align="end"8 label="Require two-factor authentication"9 description="Everyone will be prompted at next sign-in."10/>1112// Optimistic update with rollback — the pattern every switch needs13async function toggle(next: boolean) {14 const previous = enabled15 setEnabled(next) // move immediately16 setSaving(true)17 try {18 await api.updateSetting('two_factor', next)19 } catch (err) {20 setEnabled(previous) // revert21 toast({22 tone: 'danger',23 title: 'Could not save',24 description: 'Two-factor authentication is still ' + (previous ? 'on' : 'off') + '.',25 })26 } finally {27 setSaving(false)28 }29}3031// A switch that gates a section below it32<Switch checked={custom} onCheckedChange={setCustom} label="Custom domain" />33{custom && (34 <div className="mt-3 animate-[slide-up_180ms_var(--ease-emphasized)_both]">35 <TextField label="Domain" placeholder="app.acme.com" />36 </div>37)}Framework-free HTML
<div class="ds-switch-row">
<div>
<span class="ds-switch-row__label" id="tfa-label">
Require two-factor authentication
</span>
<p class="ds-switch-row__desc" id="tfa-desc">
Everyone will be prompted at next sign-in.
</p>
</div>
<button
type="button"
class="ds-switch"
role="switch"
aria-checked="true"
aria-labelledby="tfa-label"
aria-describedby="tfa-desc"
>
<span class="ds-switch__knob" aria-hidden="true"></span>
</button>
</div>
<div class="sr-only" role="status" aria-live="polite" id="tfa-status"></div>CSS
.ds-switch {
position: relative;
display: inline-flex;
align-items: center;
inline-size: 38px;
block-size: 22px;
padding: 2px;
border: 2px solid transparent; /* keeps the focus ring off the track */
border-radius: 999px;
background: var(--ds-border-strong);
transition: background-color 180ms var(--ease-emphasized);
}
.ds-switch[aria-checked='true'] { background: var(--ds-accent); }
.ds-switch:hover:not(:disabled)[aria-checked='false'] {
background: var(--ds-fg-disabled);
}
.ds-switch:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
}
/* Knob is white in BOTH themes — it is a physical object in a track */
.ds-switch__knob {
inline-size: 18px;
block-size: 18px;
border-radius: 999px;
background: #fff;
box-shadow: var(--shadow-e1);
transition: transform 180ms var(--ease-emphasized);
}
.ds-switch[aria-checked='true'] .ds-switch__knob {
transform: translateX(16px); /* transform only — never left/margin */
}
.ds-switch:disabled { opacity: 0.5; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) {
.ds-switch, .ds-switch__knob { transition-duration: 0.01ms; }
}Component API
Switch
| Prop | Type | Default | Description |
|---|---|---|---|
| checked* | boolean | — | Controlled. A switch has no uncontrolled mode by design — the value lives on the server. |
| onCheckedChange* | (v: boolean) => void | — | Fired on click, Space and Enter. |
| label | ReactNode | — | Wired with aria-labelledby. Name the setting, not the action. |
| description | ReactNode | — | Second line, wired with aria-describedby. |
| align | 'start' | 'end' | 'start' | 'end' puts the control on the right and stretches the row — the settings-list layout. |
| size | 'sm' | 'md' | 'md' | 32×18 or 38×22. |
| disabled | boolean | false | Explain why nearby — a disabled switch with no reason is a dead end. |
Professional tips
- Group related switches under a heading and separate groups with a divider. Ten ungrouped switches is a wall; three groups of three is a list.
- A switch that reveals more settings should animate them in below it, not open a dialog. The user is already in the right place.
- For destructive settings — "Allow public access", "Delete after 30 days" — keep the switch but add a confirmation dialog on the way to on. Off should never need confirming.
- Do not disable a switch while saving. Freeze the value optimistically and let the user carry on; blocking the control for a 200ms request feels broken.
Performance
- Animate transform, never left or margin-left. On a settings page with twenty switches, layout-triggering toggles are visible as jank.
- Debounce the network call, not the visual state. The switch must move at 0ms; the request can wait 300ms and coalesce rapid flips.
- Persist the whole settings object in one request when several switches change quickly, rather than one request per toggle.
Common mistakes
- Using a checkbox styled as a switch. role="switch" announces "on/off"; a checkbox announces "checked", which is the wrong model.
- Reverting on failure without telling the user, so the switch appears to have a mind of its own.
- Putting a switch inside a form with a Save button, making it ambiguous when anything applies.
- Adding On/Off text next to the switch, which duplicates the state and makes the row harder to scan.
- Making the knob match the theme surface, so at rest it is invisible against the off track.
Real-world recommendations
- Settings screens are audited, not browsed. Optimise for scanning a column of states, not for the beauty of any single row.
- For feature flags, show who last changed the value and when. A team-wide toggle with no history is a support conversation waiting to happen.
- When a switch controls something with a cost — a paid feature, extra storage — show the consequence before it applies, not after.
- Track toggles that get flipped back within a minute. That pattern almost always means the label did not describe what the switch actually did.