Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Everyone in the workspace will be prompted at next sign-in.

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.

Notifications

Changes save immediately.

Paged immediately when a service goes down.
One summary email at 09:00 in your timezone.
Anonymous performance data. No request contents.
Early access. Things may break.

Switch or checkbox?

The only question that matters: is there a Save button?

Switch — saves instantly
Applies to this browser only.
Sent as incidents happen.
Checkbox — staged until submit

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.

Succeeds
 
Fails and rolls back
 

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.

Off
On
Hover (off)
Focus
Disabled off
Disabled on
Small18 × 32
Medium22 × 38
With label
Immediate.
Two-line
Saving…
Saving
Could not save
Failed

Anatomy

Every part, every measurement, and the reason it is that number.

Everyone in the workspace will be prompted at next sign-in.

Label and description on the left, control on the right. The whole row is a settings row; only the switch is the control.

  1. 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.

  2. 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.

  3. Travel16px

    Track width minus knob width minus the insets. Animated on transform so it never triggers layout.

  4. 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.

  5. Duration180ms emphasized

    Long enough to read as movement, short enough that rapid toggling never queues up. A spring here would overshoot the track edge.

  6. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
trackMedium switch
gapControl to label in a settings row

Radius

TokenValueUsed for
full—Track and knob

Shadow

TokenValueUsed for
--shadow-e1—Knob elevation

Motion

TokenValueUsed for
--ease-emphasizedKnob travel and track colour

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingLabel gapMin widthTouch targetWhen to use
Small18px—10px32px44px (row)Dense settings tables and inline toolbar toggles.
Medium22px—12px38px44px (row)The default. Every settings list.
Row56px14px 20px———A settings row with a label and a one-line description.

Do

Put the control on the right in settings listsThe label is read first and the state is checked second. A shared right edge across every row lets the eye run down a single column to audit ten settings at once.
not "Enable dark mode"
Label the setting, not the state"Dark mode" is a thing that is on or off. "Enable dark mode" describes the action of turning it on, which becomes nonsense once it already is on.

Changes save immediately.

Say that changes save immediatelyOne sentence at the top of a settings panel removes an entire class of support ticket from people looking for a Save button that does not exist.
 
Flip optimistically, roll back visiblyThe switch should move at 0ms because that is what makes it feel instant. If the request fails, revert it and say why — silently snapping back looks like a bug.

Don't

Do not put switches in a form with a Save buttonTwo contradictory promises on one screen. Does the switch apply now, or on save? The user cannot tell, and neither can the next engineer.
Metric
Imperial
Do not use a switch for two named alternativesOn/off is not the same as metric/imperial. If both states have names, both names must be visible — that is a segmented control.
Do not use a switch for consentConsent is a deliberate form action that gets submitted and recorded. A switch implies a preference you can flip back and forth, which is exactly the wrong framing.
ON
Do not add On/Off text beside the switchThe track colour and the knob position already say it. The label then changes meaning as you toggle, which makes the row impossible to scan.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

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

TabMoves to the switch. Every switch is individually tabbable.
SpaceToggles. This is the primary binding.
EnterAlso 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.
AttributeApplied toNotes
role="switch"The controlAnnounces as "on/off" rather than "checked/unchecked", which is the correct mental model for an instant setting.
aria-checkedThe controltrue or false. Never "mixed" — a switch has no indeterminate state.
aria-labelledbyThe controlPoints at the visible label. The label is not a <label> because the control is a button, not an input.
aria-describedbyThe controlPoints at the description line.
aria-busyThe controlWhile an optimistic update is in flight, so assistive tech knows the state is provisional.
aria-live="polite"A status regionAnnounces a rollback: "Could not save. Two-factor authentication is still off."

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
labelReactNode—Wired with aria-labelledby. Name the setting, not the action.
descriptionReactNode—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.
disabledbooleanfalseExplain why nearby — a disabled switch with no reason is a dead end.

Notes

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.