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.

Each row is a label, so the whole line toggles 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.

The switch keeps a name even when the visible text is in the row
Email notifications A summary when an export finishes.
Desktop alerts Requires browser permission.
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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 :checked is 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-only text inside the label, or point the input at the title with aria-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-visible draws 2px 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 an aria-live region. 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.

The knob starts on the right and travels the other way
<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 .modal in 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.