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.

6 min read
androidcomposekotlinmaterial3

Day 37 — Checkbox, radio, switch: three controls, three different que

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.

Each control encodes when the choice takes effect and whether the options are exclusive

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.