Checkbox, RadioButton, and Switch in Jetpack Compose

Quick answer: Use a Checkbox for independent, multi-select choices; a RadioButton for exactly one choice in a group; and a Switch for an immediate on/off setting. Keep the selected value in screen state, make the label row one accessible target, and set the child control callback to null when its parent owns the interaction.

These three controls all look like toggles, but they communicate different commitments. Choosing the right one is more important than custom colors: it tells users whether they can select many values, must select one, or are changing a setting immediately.

Choose the control by the user’s decision

User decisionUseExample
Pick any number of independent itemsCheckboxProduct categories, consent choices, bulk selection
Pick exactly one option from a visible setRadioButtonDelivery speed, sort order, account type
Turn one setting on or off immediatelySwitchNotifications, dark mode, auto-sync

Android’s checkbox guidance explicitly recommends checkboxes when users can select multiple items. The radio-button guide defines a radio group as one choice from a set. Do not use a switch merely because it looks modern: a list of switches suggests every setting is independent and changes as soon as it is toggled.

Keep the value in screen state

Selection controls are controlled components: they render a value and emit a requested change. The screen state holder owns whether that change is accepted, persisted, or temporarily disabled.

data class NotificationUiState(
    val marketingEnabled: Boolean = false,
    val selectedDigest: DigestFrequency = DigestFrequency.Weekly,
)

enum class DigestFrequency { Daily, Weekly, Monthly }

sealed interface NotificationAction {
    data class MarketingChanged(val enabled: Boolean) : NotificationAction
    data class DigestFrequencyChanged(val value: DigestFrequency) : NotificationAction
}

The ViewModel updates this state after receiving an action; the UI displays it. That separation makes a setting easy to preview and test, and avoids storing a second, hidden truth inside the control. See state hoisting with practical Compose examples for the broader pattern.

Build a whole-row checkbox

A checkbox alone has a small visual target. When a label describes the same choice, let the full row toggle once and make the checkbox a visual child.

@Composable
fun MarketingPreference(
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .toggleable(
                value = checked,
                role = Role.Checkbox,
                onValueChange = onCheckedChange,
            )
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Checkbox(
            checked = checked,
            onCheckedChange = null,
        )
        Column(Modifier.padding(start = 16.dp)) {
            Text("Product updates")
            Text(
                "Occasional news and feature announcements.",
                style = MaterialTheme.typography.bodySmall,
            )
        }
    }
}

The toggleable modifier gives the parent the checkbox role and change semantics. Android’s Compose accessibility defaults recommends this pattern: put interaction on the parent and pass null to the child control, avoiding two competing accessible click targets. It also preserves the Material control’s normal visual state.

Do not use a plain clickable row for a boolean setting unless you add equivalent role and state semantics yourself. toggleable communicates the checked state to accessibility services.

Use tri-state selection for parent items

A parent checkbox can represent all, none, or some children selected. Use TriStateCheckbox and derive its state from the actual child selection.

@Composable
fun CategorySelector(
    selectedIds: Set<String>,
    categories: List<CategoryUi>,
    onSelectedIdsChange: (Set<String>) -> Unit,
) {
    val toggleState = when {
        selectedIds.isEmpty() -> ToggleableState.Off
        selectedIds.size == categories.size -> ToggleableState.On
        else -> ToggleableState.Indeterminate
    }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .toggleable(
                value = toggleState == ToggleableState.On,
                role = Role.Checkbox,
                onValueChange = { selectAll ->
                    onSelectedIdsChange(
                        if (selectAll) categories.mapTo(mutableSetOf()) { it.id }
                        else emptySet(),
                    )
                },
            )
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        TriStateCheckbox(state = toggleState, onClick = null)
        Text("All categories", Modifier.padding(start = 16.dp))
    }
}

Indeterminate means “some children are selected,” not “a third final option.” Keep the selection data in the state holder; the composition should only derive the display state from it. The Material 3 package includes TriStateCheckbox specifically for this visual state.

Make a radio group one accessible decision

For radio options, use selectableGroup() on the container and selectable() on each row. The nested RadioButton has onClick = null so TalkBack and keyboard users encounter one option, not a row and a duplicate circle.

@Composable
fun DigestFrequencyGroup(
    selected: DigestFrequency,
    onSelected: (DigestFrequency) -> Unit,
) {
    Column(Modifier.selectableGroup()) {
        DigestFrequency.entries.forEach { frequency ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .selectable(
                        selected = frequency == selected,
                        role = Role.RadioButton,
                        onClick = { onSelected(frequency) },
                    )
                    .padding(16.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                RadioButton(
                    selected = frequency == selected,
                    onClick = null,
                )
                Text(
                    text = frequency.name,
                    modifier = Modifier.padding(start = 16.dp),
                )
            }
        }
    }
}

The Android radio-button guide uses this exact structural idea and explains why selectableGroup, selectable, Role.RadioButton, and a null child callback improve accessibility. In an app, replace frequency.name with localized display text; enum names are not user-facing copy.

If many choices do not fit comfortably on screen, a dropdown or a dedicated selection screen may be easier to scan. Radio buttons work best when the complete set is small and visible.

Use a switch for immediate settings

Use a switch when tapping it takes effect now. If the change needs review and a Save button, a checkbox is often clearer because it signals inclusion in a pending form.

@Composable
fun AutoSyncSetting(
    checked: Boolean,
    enabled: Boolean,
    onCheckedChange: (Boolean) -> Unit,
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .toggleable(
                value = checked,
                enabled = enabled,
                role = Role.Switch,
                onValueChange = onCheckedChange,
            )
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Column(Modifier.weight(1f)) {
            Text("Sync over mobile data")
            Text(
                "May use your data plan.",
                style = MaterialTheme.typography.bodySmall,
            )
        }
        Switch(
            checked = checked,
            onCheckedChange = null,
            enabled = enabled,
        )
    }
}

The Switch API exposes checked, onCheckedChange, and enabled, like the other Material 3 selection controls. Use the default colors unless a state has a product-specific, accessible meaning; your Material theme should provide the normal selected, unselected, and disabled contrast.

Disabled controls need an explanation

enabled = false prevents interaction, but a disabled control without context can be confusing. Put a concise reason next to it—“Available after choosing a work account”—and offer the next action when appropriate. Do not make the label row clickable if its child is disabled: that creates contradictory behavior.

Material controls provide minimum touch-target treatment when they are interactive. When moving interaction to a parent, keep enough padding on that parent. Test touch, keyboard focus, TalkBack, large fonts, and dark mode.

Test selection behavior

Test the state transition and user-visible semantics rather than only the drawable:

composeTestRule
    .onNodeWithText("Product updates")
    .performClick()

composeTestRule
    .onNode(hasRole(Role.Checkbox) and hasStateDescription("Checked"))
    .assertExists()

Exact semantics can differ by component and language, so choose a stable test tag when a test depends on a particular control. Unit-test the ViewModel rule that accepts or rejects a change; then use UI tests to confirm the row is reachable and toggles once.

Common mistakes

Giving both the row and child their own click callback

This creates duplicate targets and can toggle twice. Put interaction and semantics on one parent, then set the child callback to null.

Using switches for multi-select lists

A switch communicates an immediate independent setting. Use checkboxes for a collection selected together or saved later.

Using a radio group without selectableGroup

The group semantics help accessibility services understand that the options are one choice set.

Treating indeterminate as a selectable final value

It is a summary of child state. A tap should normally select all or clear all, according to the current state and product convention.

FAQ

Should I put remember inside every checkbox row?

No. A reusable row receives its value and callback. Keep app and screen state in the ViewModel; only use composition state for UI machinery that cannot live elsewhere.

Can I customize Material 3 control colors?

Yes, through defaults such as CheckboxDefaults.colors() and SwitchDefaults.colors(), but first ensure the custom color preserves contrast and does not become the only indication of state.

When should a switch show a confirmation?

Usually only for a destructive, costly, or permission-sensitive change. For ordinary preferences, applying the setting immediately with clear current state is less disruptive.

Summary

Checkboxes represent independent selections, radio buttons represent one visible choice, and switches represent immediate settings. Lift state, make the full labeled row the single interaction target, use the appropriate semantic modifier, and let Material 3 provide the familiar visuals and accessible baseline.