Switch
.switch goes on a <label> wrapping a real
<input type="checkbox">, drawn as a 46×28 track with a 22px
knob. Underneath it is a checkbox, with everything a checkbox does — space to toggle,
form submission, the :checked pseudo-class. Only the painting changes.
When to use it
Use a switch when the change takes effect immediately: a setting that saves as it is flipped, a feature toggled on, a filter applied at once. The reader flips it and it is done.
If the change is collected and applied later — when they press Save — that is a checkbox, not a switch. The affordance is a promise about when something happens, and a switch that needs a Save button afterwards is breaking it.
<div class="stack stack-2" style="max-inline-size:26rem">
<label class="switch">
<input type="checkbox" checked>
<span>Two-factor authentication</span>
</label>
<label class="switch">
<input type="checkbox">
<span>Weekly digest</span>
</label>
</div>
<div class="stack stack-2" style="max-inline-size:26rem">
<label class="switch">
<input type="checkbox" checked>
<span>Two-factor authentication</span>
</label>
<label class="switch">
<input type="checkbox">
<span>Weekly digest</span>
</label>
</div>
How it is drawn
appearance: none on the input, then a
--r-full radius to make the track a pill, and an
::after for the knob. The knob is positioned with
inset-block-start: 3px and inset-inline-start: 3px, and
moves with translate: 18px 0 on :checked.
translate rather than changing inset-inline-start is
deliberate: a transform is composited, so the knob slides without triggering layout
on every toggle. It is also why the movement can be transitioned smoothly.
inset-inline-start puts the knob at the starting end of the track, which
mirrors for free. The translate does not — see
Right to left.
<div class="cluster">
<label class="switch"><input type="checkbox"><span>Off</span></label>
<label class="switch"><input type="checkbox" checked><span>On</span></label>
<label class="switch"><input type="checkbox" disabled><span>Disabled</span></label>
</div>
<div class="cluster">
<label class="switch"><input type="checkbox"><span>Off</span></label>
<label class="switch"><input type="checkbox" checked><span>On</span></label>
<label class="switch"><input type="checkbox" disabled><span>Disabled</span></label>
</div>
In a settings list
The most common home for a switch. The row is
min-block-size: var(--tap) —
44px — and the label wraps the input, so
the whole line is the target.
<div class="list" style="max-inline-size:30rem">
<div class="list-row">
<div class="list-main">
<span class="list-title">Email notifications</span>
<span class="list-sub">A summary when an export finishes.</span>
</div>
<label class="switch"><input type="checkbox" checked><span class="sr-only">Email notifications</span></label>
</div>
<div class="list-row">
<div class="list-main">
<span class="list-title">Desktop alerts</span>
<span class="list-sub">Requires browser permission.</span>
</div>
<label class="switch"><input type="checkbox"><span class="sr-only">Desktop alerts</span></label>
</div>
</div>
<div class="list" style="max-inline-size:30rem">
<div class="list-row">
<div class="list-main">
<span class="list-title">Email notifications</span>
<span class="list-sub">A summary when an export finishes.</span>
</div>
<label class="switch"><input type="checkbox" checked><span class="sr-only">Email notifications</span></label>
</div>
<div class="list-row">
<div class="list-main">
<span class="list-title">Desktop alerts</span>
<span class="list-sub">Requires browser permission.</span>
</div>
<label class="switch"><input type="checkbox"><span class="sr-only">Desktop alerts</span></label>
</div>
</div>
Note the .sr-only text inside each switch. Without it the control has no
accessible name — the row's title is a sibling, not a label — and a screen reader
announces "checkbox, not checked" with no idea what for. This is the most common
defect in a settings list, and it is invisible on screen.
role="switch"
Deck does not add role="switch" for you. Added to the input, it changes
what a screen reader announces from "checked"/"unchecked" to "on"/"off", which reads
better for a setting.
It is left to you because the role is a claim about behaviour. A switch inside a form that is saved with a button really is a checkbox — it collects a value — and labelling it a switch would be a lie about when the change takes effect. Add the role when the toggle acts immediately.
<label class="switch">
<input type="checkbox" role="switch" checked>
<span>Two-factor authentication</span>
</label>
Classes
.switch has no .switch-* variants, so the extractor does
not treat it as a component root and there is no completeness check on this page. Its
other rules — .switch input, .switch input::after — are
element selectors rather than classes.
| Class | Layer | What it does | Source |
|---|---|---|---|
.switch |
deck.components |
An on/off control that takes effect immediately, drawn over a real checkbox. If the reader has to press Save afterwards, use .check instead. Add role="switch" when "on"/"off" reads better than "checked"/"unchecked". | 06-forms.css:255 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--tap |
44px |
|
--ink-0 |
oklch(100% 0 0) |
|
--ink-300 |
oklch(84.6% calc(var(--chroma-neutral) * 2.2) var(--hue-neutral)) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--focus |
light-dark(var(--brand-500), var(--brand-300)) |
|
--r-full |
999px |
|
--shadow-2 |
0 2px 4px oklch(20% .03 var(--shadow-color) / .05), 0 4px 12px oklch(20% .03 var(--shadow-color) / .07) |
|
--space-3 |
.75rem |
|
--dur-2 |
190ms |
Accessibility
-
It is a real checkbox. Space toggles it, it is announced as a
checkbox (or a switch, with the role), it submits with the form, and
:checkedis the browser's own state rather than a class. -
Wrapping is the labelling. The
<label>contains the input, so the association cannot break and the whole row is the target as a consequence. -
A switch with no visible text still needs a name. In a settings
list the description is usually beside the control, not inside the label. Add
.sr-onlytext inside the label, or point the input at the title witharia-labelledby. - The state is not conveyed by colour alone — the knob moves, which is a positional cue that survives any colour vision. That is worth keeping in mind if you restyle it: a switch that only changes colour is not accessible.
-
Focus is a real outline.
:focus-visibledraws2px solid var(--focus)with a 2px offset on the track itself. -
Touch target. The track is 46×28, below the
44px minimum on its own — but the row is
min-block-size: var(--tap)and the label wraps the input, so the target is the row. A switch used outside a label would not clear it. -
Immediate effect needs feedback. If flipping a switch saves to a
server, say so — a
.toast, or a status line in anaria-liveregion. A switch that silently fails is worse than a checkbox with a Save button.
Right to left
The row is a flex line with a gap, so the track moves to the other side of the text on
its own, and the knob's resting position is
inset-inline-start, which mirrors with it.
The travel does not mirror. translate: 18px 0 is a
physical X offset, so in an RTL document it would push the knob further off the track
rather than across it. src/19-logical.css negates it:
[dir="rtl"] .switch input:checked::after { translate: -18px 0; }
That is the same shape of problem as the
drawer's slide: translate has no logical
form, so any component that moves along the inline axis needs a hand-written RTL rule.
It also means the magic number appears twice — see
Overriding it, where resizing the switch now means four
numbers rather than three.
<div dir="rtl" class="stack stack-2">
<label class="switch"><input type="checkbox" checked><span>المصادقة الثنائية</span></label>
<label class="switch"><input type="checkbox"><span>الملخص الأسبوعي</span></label>
</div>
<div dir="rtl" class="stack stack-2">
<label class="switch"><input type="checkbox" checked><span>المصادقة الثنائية</span></label>
<label class="switch"><input type="checkbox"><span>الملخص الأسبوعي</span></label>
</div>
Reduced motion
The track transitions its background and the knob transitions its
translate over
190ms with a spring easing. Under
prefers-reduced-motion: reduce the global reset collapses both to
.01ms, so the knob jumps rather than slides.
The state is never hidden from a reader who has asked for less motion — only the travel is removed. The knob still ends up at the other end, which is the part that carries the meaning.
Printing
src/99-print.css gives .switch input a
1px solid #000 border so the track is visible on paper. The knob's
position is what shows the state, and it prints where it is — so an on switch and an
off switch are still distinguishable.
It is a thinner treatment than checkboxes get, which are given an explicit filled style for the checked state. A printed page of switches relies on the reader noticing which side each knob is on.
Overriding it
@layer app.components {
/* A larger switch — four numbers have to move together */
.switch input { inline-size: 58px; block-size: 34px; }
.switch input::after { inline-size: 28px; block-size: 28px; }
.switch input:checked::after { translate: 24px 0; }
[dir="rtl"] .switch input:checked::after { translate: -24px 0; }
}
That is the one awkward thing about this component: the track size, the knob size and
the travel distance have to agree, and nothing checks that they do — and because
translate has no logical form, the travel is written
twice, once here and once negated in
src/19-logical.css. Change it and forget the RTL copy, and the switch
breaks only for readers of Arabic and Hebrew. A version driven by a single
--switch-size with the rest derived by calc() would fix
both problems; it is recorded in FINDINGS.md.
When not to use it
-
Not in a form with a Save button. That is a
.check. The switch affordance says "this is already done", and it should not be used when it is not. -
Not for one of several options. A switch is on or off. One choice
from three is a radio group or
.segmented. -
Not for a destructive setting without confirmation. A switch is
one tap and immediate. "Delete all exports after 30 days" needs a
.modalin front of it, or it needs to be a checkbox that is saved deliberately. - Not for something that might fail. If the change goes to a server that can reject it, the switch will show the new state before the truth is known. Either revert it visibly on failure, or use a control with an explicit Save.
- Not for agreeing to terms. Consent needs an unambiguous, deliberate act, and a checkbox is the convention readers and regulators both expect.