Checkbox, radio, switch: three controls, three different questions
Checkboxes, radio buttons and switches encode different semantics — multiple choice, exclusive choice, and immediate effect. Choosing by appearance produces interfaces that mislead, and the accessibility story differs too.

Day 37 of 100. Three small components that are chosen by habit far more often than by meaning, and each wrong choice tells the user something untrue.
The symptom
A settings screen where nothing happens when you expect it to:
Column {
Row { Checkbox(checked = darkMode, onCheckedChange = { darkMode = it }); Text("Dark mode") }
Row { Checkbox(checked = notifications, onCheckedChange = { … }); Text("Notifications") }
Row { Checkbox(checked = analytics, onCheckedChange = { … }); Text("Share analytics") }
}
It works. And users look for a Save button, because a checkbox implies a form — a set of selections you confirm together. There isn't one, so some users leave the screen unsure whether their change took.
Why the obvious fix fails
The obvious fix is to add the Save button they're looking for:
Button(onClick = ::saveSettings) { Text("Save") }
Now the screen has a submit step that the underlying storage doesn't need, plus a half-edited state to manage, plus an "unsaved changes" dialog on back. You've added a state machine to justify a component choice.
The real fix is one line and no new UI.

The actual mechanism
Three controls, three distinct meanings:
Switch — takes effect immediately. A setting that is on or off, applied the moment
it's toggled. No confirmation, no Save.
Checkbox — one of several independent choices, applied later. Part of a form. Zero
or more may be selected, and something else commits them.
RadioButton — exactly one of a set, mutually exclusive. Choosing one deselects the
others.
The settings screen is switches:
Column {
SettingRow("Dark mode", darkMode, onChange = viewModel::setDarkMode)
}
@Composable
fun SettingRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.toggleable(value = checked, onValueChange = onChange, role = Role.Switch)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(label, Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = null) // null — the ROW handles it
}
}
Two details there matter more than the component choice.
The whole row should be the target
Modifier.toggleable on the row, with onCheckedChange = null on the control itself, is
the correct pattern — and it's not just ergonomics:
- The touch target becomes the full row rather than a 48dp square, which matters for motor accessibility.
- The label and the control are merged into one node in the semantics tree, so a screen reader announces "Dark mode, switch, on" as a single item.
Leave onCheckedChange on the Switch and you get two focusable nodes — an unlabelled
switch and a label that isn't a control — plus a double toggle when both fire.
Use selectable instead of toggleable for radio buttons, and Role.RadioButton /
Role.Checkbox / Role.Switch to match. The role is what a screen reader announces, and
it's the accessibility half of the semantic choice this post is about.
For a radio group, add Modifier.selectableGroup() on the container so assistive
technology announces "1 of 3":
Column(Modifier.selectableGroup()) {
options.forEach { option ->
Row(
Modifier.selectable(
selected = option == selected,
onClick = { onSelect(option) },
role = Role.RadioButton,
)
) {
RadioButton(selected = option == selected, onClick = null)
Text(option.label)
}
}
}
The tri-state case
A "select all" checkbox that reflects a partial selection has a third state, and there's a component for it rather than two booleans:
val parentState = when {
items.all { it.checked } -> ToggleableState.On
items.none { it.checked } -> ToggleableState.Off
else -> ToggleableState.Indeterminate
}
TriStateCheckbox(
state = parentState,
onClick = { setAll(parentState != ToggleableState.On) },
)
Deriving parentState from the children rather than storing it is Day 13's rule — if you
can compute it from other state, it isn't state, and a stored parent flag will eventually
disagree with its children.
Where they go
One layout convention worth following because users rely on it:
- Switches go on the trailing edge. The label is the subject, the switch is the answer.
- Checkboxes and radio buttons go on the leading edge, so a vertical list of options has its controls aligned in a scannable column.
Putting a checkbox on the right of a list of options makes the group noticeably harder to scan, because the eye has to travel to a ragged right margin to read the state.
Material's own components encode this: ListItem puts a trailingContent slot where a
switch belongs and a leadingContent slot where a checkbox does, so following the
convention is mostly a matter of using the right slot.
How to prove it
The semantics merge is the thing worth asserting, because it's invisible until someone uses a screen reader:
@Test fun rowIsASingleToggleableNode() = runComposeUiTest {
setContent { SettingRow("Dark mode", true) {} }
onNodeWithText("Dark mode").assertIsOn().performClick()
onAllNodes(isToggleable()).assertCountEquals(1) // ONE node, not two
}
If that count is 2, onCheckedChange is still on the control and the row has a duplicate
target.
The meaning question has no automated test, only one that works reliably: ask whether there's a Save button. If there is, checkboxes. If there isn't, switches. A screen with switches and a Save button is contradicting itself.
The immediate-effect promise has consequences
Choosing Switch is a promise that the change applied. Two things follow, and skipping
them makes the switch a lie:
Optimistic update with rollback. The toggle should move instantly, then revert if the write fails:
fun setDarkMode(enabled: Boolean) = viewModelScope.launch {
_uiState.update { it.copy(darkMode = enabled) } // move now
runCatching { prefs.setDarkMode(enabled) }
.onFailure {
_uiState.update { it.copy(darkMode = !enabled) } // put it back
_events.send(Event.SaveFailed)
}
}
A switch that snaps back with no explanation is worse than one that never moved.
No spinner in place of the control. Replacing the switch with a progress indicator while a 200ms write completes produces a flicker and shifts the layout. If the operation is genuinely slow enough to need feedback, the setting probably isn't a switch.
The one case where a switch legitimately takes time is a network-backed toggle. There,
disabling it during the write — Switch(enabled = !saving) — keeps the control in place
and prevents a second tap, which is the same shape as yesterday's loading button.
What this generalizes to
The idea is components carry semantics, not just pixels. A switch tells the user "this is live"; a checkbox tells them "this is a form"; a radio tells them "you get one". Those messages are delivered before anything is read, and they're delivered whether or not you intended them.
That's also why the accessibility API mirrors the visual one so closely: Role.Switch
exists because the meaning differs, not the drawing. HTML made the same
distinction thirty years ago with checkbox, radio and a type attribute, and
frameworks that collapsed them into one styleable toggle have been rediscovering the cost
ever since. When the visual and the semantic
choice agree, both audiences get the same interface — which is the whole design goal.
Tomorrow, Day 38: text fields — state hoisting's hardest case, and the difference between
the two TextField overloads.
Day 37 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Checkboxes.