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.
44 · min48×28180ms · emphasizedUsage
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.
The interactive shape. Switch: pill. Checkbox: rounded square. Radio: circle.
The thumb (switch), the check or dash (checkbox), the inner dot (radio). Animates between states.
Always present, always clickable. Describes the state, not the action.
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
Checkbox · Web
Radio · Web
Platforms
Same intent, three native shapes. Don't mix in a single screen.
Switch
Checkbox
Radio
Dimensions
Track sizes and hit areas across platforms.
Switch
| Web · track | 48 × 28 px · radius 9999px |
|---|---|
| Web · thumb | 24 × 24 px · 2 px inset |
| iOS · track | 51 × 31 pt |
| iOS · thumb | 27 × 27 pt |
| Android M3 · track | 52 × 32 dp |
| Android M3 · thumb | 16 dp off · 24 dp on (with check icon) |
| Hit area (all) | 44 × 44 pt min · 48 × 48 dp on Android |
Checkbox
| Web | 20 × 20 px · radius 6 · stroke 1.8 |
|---|---|
| iOS (circle) | 24 × 24 pt · radius 50% |
| Android M3 | 18 × 18 dp · radius 2 · stroke 2 |
| Check glyph | 12 px stroke-width 3 |
| Indeterminate dash | 10 × 2 px centered |
Radio
| Web | 20 × 20 px · stroke 1.8 · dot 10 px |
|---|---|
| iOS | 22 × 22 pt · filled blue when on |
| Android M3 | 20 × 20 dp · stroke 2 · dot 10 dp |
Typography
Labels follow the platform body type. Size matches form-field labels.
| Web · label | STK Bureau Sans · 500 · 14/20 |
|---|---|
| Web · helper | STK Bureau Sans · 400 · 13/18 · --fg-subtle |
| iOS · label | SF Pro Text · 400 · 17/22 |
| Android · label | Roboto · 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 |
| Disabled | opacity: 0.5 · cursor not-allowed |
iOS
| Switch · on | systemGreen |
|---|---|
| Switch · off | tertiarySystemFill |
| Radio · on | systemBlue |
| Check · on | systemGreen |
Android · M3
| Track · on | md.sys.color.primary · brand-mapped to #FF550C |
|---|---|
| Track · off | md.sys.color.surface-container-highest |
| Thumb · on | md.sys.color.on-primary |
| Outline | md.sys.color.outline · #79747E |
Motion
All thumb/dot transitions use the same easing for consistency.
| Switch · thumb | 180ms · cubic-bezier(0.16, 1, 0.3, 1) (emphasized) |
|---|---|
| Track · color | 140ms · linear |
| Checkbox · check | 120ms · ease-out |
| Radio · dot | 160ms · cubic-bezier(0.16, 1, 0.3, 1) (scale 0 → 1) |
| Android M3 · thumb size | 220ms · M3 emphasized · 16dp → 24dp on toggle |
Spacing
Gap between control and label, and between stacked controls.
| Control ↔ label | 10 px (web) · 12 pt (iOS) · 16 dp (Android) |
|---|---|
| Stacked toggles | 14 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.
Use a switch when the change takes effect immediately. The act of flipping it is the commit.
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
Name the thing being toggled. The state of the switch tells you whether it's on.
"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
Use radios when there are 2–5 options and people need to compare them at a glance.
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-primaryon web. - Hit area: minimum
44×44pt 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 stateonChange— fired on commitdisabled— opacity 0.5, cursor not-allowedindeterminate— checkbox onlylabel— required, never blankhelper— optional supporting textname— required for radio groupsplatform—"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;