Design System /Components /Toggles

Toggles

Three controls that all answer one question — yes or no. Switches commit immediately, checkboxes gather intent, radios pick one from a set.

Playground

Pick a control and a platform. The specimen, spec block, and snippet update in lockstep.

Live Preview Flip Props
WEB · ON
Control
Switch
Hit area
44 · min
Track
48×28
Motion
180ms · emphasized

Usage

Pick the control by what the user is doing — not by how it looks.

Use a switch when the change should take effect immediately with no further confirmation — protection on/off, weekly digest on/off. Use a checkbox when the user is gathering intent that will be committed later by a Save or Continue button — terms agreement, multi-select lists, settings forms. Use a radio when there is a small mutually-exclusive set of options and the user must pick exactly one.

Switches always need a label that describes the state being turned on ("Email digest"), never the action ("Turn on email digest"). Checkboxes and radios always need a clickable label — the box alone is never enough hit-target on touch devices.

Types

One control per question shape. Don't mix.

Switch

Binary, immediate. The control is the commit.

Checkbox

Multi-select or commit-on-submit. Box alone has no semantic meaning.

Radio

One-of-N. When you have more than five options, use a select instead.

Indeterminate

Checkboxes only. Use for "some children selected" parent states in lists and trees.

Anatomy

A toggle is the control plus a clickable label. The label is half the control, not a sticker beside it.

1Track

The interactive shape. Switch: pill. Checkbox: rounded square. Radio: circle.

2Indicator

The thumb (switch), the check or dash (checkbox), the inner dot (radio). Animates between states.

3Label

Always present, always clickable. Describes the state, not the action.

4Hit area

44pt minimum on iOS, 48dp on Android. The label is part of the hit area.

States

Five states across all three controls. Not every state applies to every control — indeterminate is checkbox-only.

Switch · Web

Off
On
Disabled · off
Disabled · on

Checkbox · Web

Off
On
Indeterminate
Disabled

Radio · Web

Off
On
Disabled · off
Disabled · on

Platforms

Same intent, three native shapes. Don't mix in a single screen.

Switch

Web
iOS
Android · M3

Checkbox

Web
iOS
Android · M3

Radio

Web
iOS
Android · M3

Dimensions

Track sizes and hit areas across platforms.

Switch

Web · track48 × 28 px · radius 9999px
Web · thumb24 × 24 px · 2 px inset
iOS · track51 × 31 pt
iOS · thumb27 × 27 pt
Android M3 · track52 × 32 dp
Android M3 · thumb16 dp off · 24 dp on (with check icon)
Hit area (all)44 × 44 pt min · 48 × 48 dp on Android

Checkbox

Web20 × 20 px · radius 6 · stroke 1.8
iOS (circle)24 × 24 pt · radius 50%
Android M318 × 18 dp · radius 2 · stroke 2
Check glyph12 px stroke-width 3
Indeterminate dash10 × 2 px centered

Radio

Web20 × 20 px · stroke 1.8 · dot 10 px
iOS22 × 22 pt · filled blue when on
Android M320 × 20 dp · stroke 2 · dot 10 dp

Typography

Labels follow the platform body type. Size matches form-field labels.

Web · labelSTK Bureau Sans · 500 · 14/20
Web · helperSTK Bureau Sans · 400 · 13/18 · --fg-subtle
iOS · labelSF Pro Text · 400 · 17/22
Android · labelRoboto · 400 · 16/24

Color styles

All "on" states use the brand accent. Platform conventions override on iOS (green switch, blue radio).

Web

Track · off--border-strong
Track · on--accent-primary · #FF550C
Thumb#FFFFFF
Box · on--accent-primary
Check glyph#FFFFFF
Disabledopacity: 0.5 · cursor not-allowed

iOS

Switch · onsystemGreen
Switch · offtertiarySystemFill
Radio · onsystemBlue
Check · onsystemGreen

Android · M3

Track · onmd.sys.color.primary · brand-mapped to #FF550C
Track · offmd.sys.color.surface-container-highest
Thumb · onmd.sys.color.on-primary
Outlinemd.sys.color.outline · #79747E

Motion

All thumb/dot transitions use the same easing for consistency.

Switch · thumb180ms · cubic-bezier(0.16, 1, 0.3, 1) (emphasized)
Track · color140ms · linear
Checkbox · check120ms · ease-out
Radio · dot160ms · cubic-bezier(0.16, 1, 0.3, 1) (scale 0 → 1)
Android M3 · thumb size220ms · M3 emphasized · 16dp → 24dp on toggle

Spacing

Gap between control and label, and between stacked controls.

Control ↔ label10 px (web) · 12 pt (iOS) · 16 dp (Android)
Stacked toggles14 px gap (web list) · 0 on iOS form rows (separator does the work)
Inline group (radios)20 px horizontal gap minimum

Pick the right control

If you can't decide, you've probably misread the question.

Do

Use a switch when the change takes effect immediately. The act of flipping it is the commit.

Don't

Don't use a switch for an action that needs confirmation or a payload — that's a button.

Labels describe the state, not the action

Do

Name the thing being toggled. The state of the switch tells you whether it's on.

Don't

"Turn on" and "Disable" create double-negatives — users can't tell what state they're in.

Stack consistently

When you have a vertical list of toggles, always put the control on the left so the labels share a left edge. iOS list rows are the only exception — there the platform expects switches on the right.

Radio vs. Select

Do

Use radios when there are 2–5 options and people need to compare them at a glance.

Don't

Past five options, switch to a select — the visual weight of the radios overpowers the labels.

Don't mix platforms

A single screen should ship one toggle vocabulary — web, iOS, or Android. Never mix the iOS green switch with the Android M3 thumb in the same view, even if the rest of the screen is "platform-agnostic." It looks like a bug.

Accessibility

Toggles are state. State has to be announced.

  • Native <input type="checkbox" role="switch"> on web — gets the right semantics for free.
  • Always wrap in a <label> so the label is part of the hit target and the screen reader pairing.
  • Use aria-checked="mixed" for indeterminate checkboxes.
  • Focus ring must be visible — minimum 2px outline at --accent-primary on web.
  • Hit area: minimum 44×44 pt on touch devices, even when the visible track is smaller.

Web (HTML)

Always wrap the input in a label so the label and box share a hit area.

Switch

<label class="toggle">
  <input type="checkbox" role="switch" checked>
  <span class="track"><span class="thumb"></span></span>
  <span class="label">Auto-renew protection</span>
</label>

Checkbox

<label class="check">
  <input type="checkbox">
  <span class="box"><svg ... /></span>
  <span class="label">I agree</span>
</label>

Radio group

<fieldset class="radio-group">
  <legend>Plan</legend>
  <label class="radio">
    <input type="radio" name="plan" checked>
    <span class="dot"></span>
    Monthly · $9.99
  </label>
</fieldset>

iOS · SwiftUI

// Switch
Toggle("Auto-renew protection", isOn: $autoRenew)
  .tint(.cloakedAccent)

// Checkbox (custom — UIKit/SwiftUI has no native check)
CloakedCheck(isOn: $accepted)
  .label("I agree to the terms")

// Radio (Picker with .segmented or custom)
Picker("Plan", selection: $plan) {
  Text("Monthly").tag(Plan.monthly)
  Text("Annual").tag(Plan.annual)
}
.pickerStyle(.inline)

Android · Jetpack Compose

// Switch (M3)
Switch(
  checked = autoRenew,
  onCheckedChange = { autoRenew = it },
  thumbContent = { if (autoRenew) Icon(Icons.Filled.Check, null) }
)

// Checkbox
Checkbox(checked = accepted, onCheckedChange = { accepted = it })

// Radio
RadioButton(selected = plan == Plan.Monthly, onClick = { plan = Plan.Monthly })

Props

  • checked — boolean state
  • onChange — fired on commit
  • disabled — opacity 0.5, cursor not-allowed
  • indeterminate — checkbox only
  • label — required, never blank
  • helper — optional supporting text
  • name — required for radio groups
  • platform"web" | "ios" | "android"

CSS tokens

/* Track */
--toggle-track-off: var(--border-strong);
--toggle-track-on: var(--accent-primary);

/* Indicator */
--toggle-thumb: #FFFFFF;
--toggle-check: #FFFFFF;
--toggle-dot: var(--accent-primary);

/* Motion */
--toggle-ease: cubic-bezier(0.16, 1, 0.3, 1);
--toggle-duration: 180ms;