Compose Semantics: Roles, Labels, States, and Actions

Quick answer: Prefer Material components and interaction modifiers such as clickable, toggleable, and selectable first: they already publish useful semantics. Add Modifier.semantics only when a custom component is missing meaning that a user needs—its role, label, current state, heading or collection context, or an accessible action. A semantic role alone does not make a control interactive; pair it with the modifier or component that implements the behavior.

Semantics is the structured description of a Compose UI that accessibility services, Autofill, and Compose tests can read. It is how TalkBack learns that a node is a switch rather than generic text, that it is currently on, and what happens when it is activated. The Compose semantics guide calls this the meaning and role supplied in addition to visual appearance.

Start with the semantics Compose already provides

Material, Foundation, and the common interaction modifiers are the safest baseline. Button, Switch, Checkbox, TextField, and Slider expose their expected roles, actions, and important values. Likewise, clickable adds a click action and toggleable adds toggle behavior and state.

NeedPreferWhy
Trigger one actionButton or Modifier.clickableAnnounces an actionable control and provides an activation action.
Change a Boolean settingSwitch, Checkbox, or Modifier.toggleableExposes the selected state as well as the action.
Choose one item in a groupModifier.selectable inside selectableGroup()Preserves the radio-style selection model.
Enter a valueTextFieldPublishes editing, text, error, and focus semantics.

This is why a custom rectangle that only has semantics { role = Role.Button } is incomplete. It may sound like a button, but it still needs a real click handler, touch behavior, and an activation action. Use clickable or a Material Button to get the behavior and the semantics together. For whole-row selection controls, Checkbox, RadioButton, and Switch in Jetpack Compose shows the same parent-interaction pattern.

Think in four pieces of meaning

When a custom component needs extra semantics, identify the missing piece rather than adding every property by habit.

Semantic pieceWhat a screen reader needs to knowExample
RoleWhat kind of control this isbutton, switch, checkbox, tab
LabelWhich item or purpose the node represents“Daily digest” or “Open article”
StateIts current, changing valuechecked, selected, “Subscribed”
ActionWhat activation or another supported gesture doesclick, dismiss, “Remove article”

Visible text often supplies the label automatically. An icon-only control needs a localized contentDescription; an action whose result is unclear can use onClickLabel. Accessibility in Jetpack Compose: Content Descriptions Done Right covers how to decide whether a graphic needs its own label.

Add role and state to a custom toggle row

Suppose a settings row has a title, explanatory text, and a trailing visual indicator. The entire row should toggle once. toggleable supplies the interaction and Role.Switch tells assistive technology that this is an immediate setting.

@Composable
fun TopicSubscriptionRow(
    topic: String,
    subscribed: Boolean,
    onSubscribedChange: (Boolean) -> Unit,
) {
    val state = if (subscribed) "Subscribed" else "Not subscribed"

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .semantics {
                // Override the generic “on” / “off” announcement.
                stateDescription = state
            }
            .toggleable(
                value = subscribed,
                role = Role.Switch,
                onValueChange = onSubscribedChange,
            )
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Column(Modifier.weight(1f)) {
            Text(topic)
            Text(
                "Receive new articles in this topic.",
                style = MaterialTheme.typography.bodySmall,
            )
        }
        Switch(
            checked = subscribed,
            onCheckedChange = null,
        )
    }
}

Place the semantics modifier before toggleable when overriding stateDescription. This ordering is documented in the Compose semantics API guidance and ensures the custom description is used for the toggle. Keep the description synchronized with the actual state and localize it with string resources in production.

Use an override only when the default words are genuinely less helpful. “On” and “off” are excellent for most switches; “Subscribed” and “Not subscribed” add useful domain meaning here. Do not expose a state that the component does not visually and behaviorally have.

Name the outcome of an action

A custom clickable row often has enough visible text to identify itself. What may be missing is a clear explanation of activation. Add a localized onClickLabel to the interaction, not a redundant description on every child icon.

Row(
    modifier = Modifier
        .fillMaxWidth()
        .clickable(
            onClickLabel = stringResource(R.string.open_article),
            role = Role.Button,
            onClick = onOpenArticle,
        )
        .padding(16.dp),
) {
    Icon(
        imageVector = Icons.Outlined.Article,
        contentDescription = null,
    )
    Text(article.title, Modifier.padding(start = 16.dp))
}

The title identifies the destination, while the click label names the result. This is preferable to manually setting a button role on a Row: clickable makes the row operable and exposes the activation action. Use combinedClickable when the long press is a real secondary action, and label it when its effect would otherwise be ambiguous.

Expose a custom gesture as an action

Some touch interactions—such as swipe to dismiss—are not obvious or practical for every user. Surface an equivalent CustomAccessibilityAction with a short verb phrase. Android’s accessibility principles recommend this approach for actions that are otherwise available only by gesture.

@Composable
fun SavedArticleRow(
    article: ArticleUi,
    onRemove: () -> Unit,
) {
    Row(
        modifier = Modifier.clearAndSetSemantics {
            contentDescription = article.title
            customActions = listOf(
                CustomAccessibilityAction(
                    label = "Remove saved article",
                    action = {
                        onRemove()
                        true
                    },
                ),
            )
        },
    ) {
        ArticleSummary(article)
    }
}

clearAndSetSemantics is deliberately powerful: it replaces this node’s semantics and clears descendant semantics for every consumer, including tests. Here the parent restores the article title and exposes a replacement action, so there is one coherent accessibility stop. Use it surgically. If a child still has an independent, useful action or label, clearing it can make the screen less usable. The merging and clearing semantics guide explains the difference between grouping descendants and replacing their semantics.

Add structure only when it changes navigation

Semantics is also useful for screen structure. For example, mark a visual section title as a heading so users can navigate between sections:

Text(
    text = "Recommended topics",
    modifier = Modifier.semantics { heading() },
    style = MaterialTheme.typography.titleLarge,
)

Use liveRegion for important updates that should be announced without moving focus, such as a validation result. Choose LiveRegionMode.Polite for most updates, and avoid repeatedly updating content such as a timer: frequent announcements interrupt people rather than helping them. For progress, collections, panes, tabs, and other complex patterns, use the dedicated semantic properties described in the Compose accessibility documentation, not a generic content description.

Understand merged and unmerged semantics

Compose builds both a merged and an unmerged semantics tree. The merged tree is usually what a person using TalkBack experiences and what Compose test matchers inspect by default. A Button commonly merges its icon and text into one accessible target—that is why an icon beside a visible button label should normally have contentDescription = null.

You can request grouping with Modifier.semantics(mergeDescendants = true), but do not use it to hide useful independent controls. Conversely, use the unmerged tree in a test only when you intentionally need to reach a descendant that a parent has merged. The Compose testing semantics guide is the right reference for inspecting the tree when a matcher seems surprising.

Test the contract, then test the experience

Semantics make accessibility expectations testable. Assert behavior instead of relying only on a text string:

import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.onNode

composeTestRule
    .onNode(
        SemanticsMatcher.expectValue(
            SemanticsProperties.Role,
            Role.Switch,
        ),
    )
    .assertIsOn()

In a real test, combine this with a stable label, test tag, or parent matcher so the test selects the intended switch. Find Nodes and Assert UI with Compose Test APIs explains those selector choices. Then use TalkBack on an emulator or device to confirm the spoken order, labels, state changes, and available actions make sense in the full screen.

Common semantics mistakes

Adding a role without behavior

semantics { role = Role.Button } does not create a click action. Prefer Button or clickable; a role should describe a behavior that actually exists.

Replacing helpful defaults unnecessarily

Material controls already expose excellent roles and values. Extra semantics can overwrite or duplicate those defaults, making announcements noisier or incorrect.

Creating duplicate focus stops

When a parent row owns an interaction, give the child Switch, Checkbox, or RadioButton a null callback. Two interactive nodes for one choice make navigation slower and behavior ambiguous.

Clearing children without restoring meaning

clearAndSetSemantics {} can remove the only accessible name or action inside a group. If you clear descendants, explicitly supply the essential parent label, state, and action, then verify the result with TalkBack and tests.

Semantics checklist

  • Prefer a standard component or interaction modifier before writing custom semantics.
  • Make role, state, and action agree with the visual behavior.
  • Use localized, outcome-oriented labels; do not duplicate visible text or roles.
  • Add stateDescription only when the default state words lack useful domain context.
  • Provide an accessible custom action for important gesture-only behavior.
  • Merge or clear semantics only when it produces one clearer, fully meaningful target.
  • Verify semantic properties in UI tests and listen to the complete interaction with TalkBack.

Good semantics do not narrate every pixel. They give each meaningful target the smallest complete explanation of what it is, what state it is in, and what a person can do with it.