Android – Accessibility in Compose

September 28, 20269 min readUpdated 10/11/2026

A blind customer orders pizza with TalkBack, Android's screen reader: they swipe from element to element and hear each one read aloud, then double-tap to activate. A customer with a tremor needs targets big enough to hit. A customer with low vision sets the font to 200%. None of them is an edge case — together they are a large share of any app's users — and a Compose app serves them through one mechanism.

The semantics tree

Alongside the UI it draws, Compose builds a semantics tree: a parallel description of what is on screen — this is a button labelled "Checkout", this is a heading, this checkbox is checked. TalkBack reads that tree, not the pixels. So do Compose UI tests: a test that finds a button by its label is using the same tree TalkBack uses, which is why accessibility work and testability turn out to be the same work.

Material components populate the tree for you. A Button is announced as a button with its text; a Checkbox reports checked or not; a segmented button reports "selected, 1 of 2". Most of the work is in custom layouts — rows you made clickable, icons, numbers, things that change. This lesson goes through each, in the order a TalkBack user would meet them.

Labels

An icon has no text, so it needs a contentDescription. The good ones describe the purpose, and include state when the state matters:

private fun CartButton(count: Int, onClick: () -> Unit) {
    IconButton(
        onClick = onClick,
        // One label that includes the count, rather than "Cart" followed by an unlabelled "3".
        modifier = Modifier.semantics { contentDescription = if (count == 0) "Cart, empty" else "Cart, $count items" },
    ) {
        BadgedBox(badge = { if (count > 0) Badge { Text("$count") } }) {
            Icon(Icons.Filled.ShoppingCart, contentDescription = null)
        }
    }
}

"Cart, 3 items" — one label that carries the count, instead of "Cart" followed by an unlabelled "3" that the listener has to pair up. The icon itself is given contentDescription = null because the button around it already says everything.

null is also right for decoration. The product cards use emoji as artwork; read aloud, "slice of pizza" before every product name is noise. clearAndSetSemantics { } removes an element from the tree entirely:

// Decorative: TalkBack reads the title, not "pizza emoji".
Text(emoji, fontSize = 48.sp, modifier = Modifier.clearAndSetSemantics { })

Merging: one stop, not five

A product card contains an emoji, a name, a description and a price. Without intervention TalkBack stops on each text separately, and the customer has to assemble "Pepperoni… Classic pepperoni… From $10.99" in their head before deciding whether to double-tap — and double-tapping a text does nothing, because the clickable thing is the card around it. Merging makes the card one element:

CardContainer(
    modifier = modifier
        .fillMaxWidth()
        .clip(RoundedCornerShape(Radius.md))
        .clickable(enabled = price != null, role = Role.Button, onClick = onClick)
        // One TalkBack stop with one sentence, instead of four fragments.
        .semantics(mergeDescendants = true) {
            contentDescription = "${product.name}. ${product.description}. $priceLabel"
        },

mergeDescendants = true folds the children's semantics into this node, and the explicit description gives it one sentence, in the order a person would say it. Combined with clickable(role = Role.Button), TalkBack says "Pepperoni. Classic pepperoni over mozzarella. From $10.99. Button. Double-tap to activate." One swipe, one sentence, one action.

The same idea, smaller: a price row is a label and an amount that only make sense together.

fun PriceRow(label: String, amount: Double, modifier: Modifier = Modifier, emphasized: Boolean = false) {
    val style = if (emphasized) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium
    val color = if (emphasized) PizzaTheme.colors.text else PizzaTheme.colors.textMuted

    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(vertical = Spacing.xs / 2)
            .semantics(mergeDescendants = true) {},
        // …

The merge trap

Merging has one trap, and it is easy to fall into: never merge a container that holds its own buttons. A cart line has a name, a price, a quantity stepper and a delete button. Merge the row and the stepper and the delete button collapse into one node, and a TalkBack user can no longer reach them separately — they cannot remove the item at all. The cart row is deliberately not merged; its controls stay individual stops, each with a label naming the product:

Icon(Icons.Outlined.Delete, contentDescription = "Remove ${item.productName}", tint = PizzaTheme.colors.textMuted)

"Remove Pepperoni Pizza", not "Delete" — because with three lines in the cart, three buttons all called "Delete" leave the listener guessing which is which.

Roles and states

A clickable row is not a button unless you say so. role = Role.Button, Role.RadioButton, Role.Checkbox tell TalkBack what kind of control it is and how to announce it. The address picker at checkout makes each row a radio button with a selected state (the forms lesson). State that has no standard property goes in stateDescription:

// TalkBack hears "Place order, busy" rather than an unlabelled spinner.
.semantics { if (isLoading) stateDescription = "Busy" }

While submitting, the button is announced as "Place order, busy, disabled" rather than as a silent spinner.

One adjustable control instead of three

The quantity stepper is drawn as three things: −, a number, +. Read naively, that is three stops: "Decrease, button", "2", "Increase, button". A sighted user sees one control; a TalkBack user should hear one:

modifier = modifier.semantics(mergeDescendants = true) {
    contentDescription = label
    stateDescription = "$value"
    progressBarRangeInfo = ProgressBarRangeInfo(
        current = value.toFloat(),
        range = range.first.toFloat()..range.last.toFloat(),
        steps = range.last - range.first - 1,
    )
    setProgress { target ->
        val next = target.toInt().coerceIn(range)
        if (next != value) onValueChange(next)
        true
    }
},

It is described as a range — like a slider — with a current value and an action to set it. TalkBack then announces "Quantity, 2" and lets the user swipe up or down to change it, the standard gesture for adjustable controls. The number in the middle is removed from the tree so it is not read twice. This is the Compose spelling of what iOS calls an adjustable element.

Note the comparison with the cart row: there, merging would have hidden actions. Here, merging and exposing setProgress keeps the action reachable through the gesture.

Errors, headings and changes

Errors are written into the field's semantics, so TalkBack reads "Error: Five digits, please" when the field is focused:

if (error != null) error(error)

Headings let a TalkBack user jump between sections instead of swiping through every element. Any text that titles a section gets Modifier.semantics { heading() } — every section title in this app has it.

Changes that happen without the user's action — a toast appearing, a receipt changing from "Confirming your payment" to "Thank you" — are invisible to someone who is not looking at them. A live region announces them:

Text(
    text = toast.message,
    color = colors.onPrimary,
    style = MaterialTheme.typography.bodyMedium,
    modifier = Modifier
        .fillMaxWidth()
        .shadow(6.dp, RoundedCornerShape(Radius.md))
        .background(background, RoundedCornerShape(Radius.md))
        .clickable(onClickLabel = "Dismiss", onClick = onClick)
        .padding(Spacing.lg)
        .semantics { liveRegion = LiveRegionMode.Polite },
)

Polite waits for TalkBack to finish what it is reading; Assertive interrupts. Use assertive only for something genuinely urgent — an error that blocks the task — or it becomes noise.

Colour is never the only signal

About one man in twelve has some form of colour blindness, and a screen reader does not see colour at all. Anything that colour communicates must also be communicated another way. Two places in this app where that is easy to get wrong:

The builder's topping chips turn red when selected. That alone would make "selected" invisible to a colour-blind customer, so a selected chip also gains a tick:

private fun ChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) {
    FilterChip(
        selected = selected,
        onClick = onClick,
        label = { Text(label) },
        leadingIcon = if (selected) {
            { Icon(Icons.Filled.Check, contentDescription = null) }
        } else {
            null
        },
        colors = FilterChipDefaults.filterChipColors(
            selectedContainerColor = PizzaTheme.colors.primarySoft,
            selectedLabelColor = PizzaTheme.colors.primaryDark,
            selectedLeadingIconColor = PizzaTheme.colors.primaryDark,
        ),
    )
}

FilterChip also reports its selected state in semantics, so TalkBack says "Bacon, selected" — the tick is for eyes, the state is for the screen reader, and the colour is decoration on top.

The order status badge is green, amber or red, but it also says the status in words, and gives TalkBack a sentence rather than shouted capitals:

// Read as words, not shouted capitals.
.semantics { contentDescription = "Status: ${status.displayName}" },

Contrast is the other half. WCAG asks for at least 4.5:1 between body text and its background. Measured for this lesson, the muted grey used for secondary text is 5.1:1 against the cream background and the brand red 4.95:1 — both pass. The palette's lighter "subtle" grey is only 2.3:1, and measuring found it in exactly one place: the "Unavailable" label on a product with no sizes. That label is information, so it now uses the muted grey, and the subtle one is documented as unfit for text:

// textMuted, not textSubtle: "Unavailable" is information, and the subtle grey
// is only 2.3:1 against the card — below WCAG's 4.5:1 for text.
color = if (price != null) PizzaTheme.colors.primary else PizzaTheme.colors.textMuted,

Accessibility Scanner flags exactly this kind of problem on a running screen, which is faster than computing ratios by hand.

Touch targets and text size

Every tappable element is at least 48dp in both directions, which is Material's guidance and a design-system token in this app:

val MinTouchTarget = 48.dp

Material components already enforce it — an IconButton is 48dp even though its icon is 24 — so the work is in custom controls: the stepper uses IconButtons, the address rows make the whole row selectable rather than the 20dp circle, and the button component sets heightIn(min = MinTouchTarget).

Text uses sp throughout, so it scales with the customer's font setting; layouts use heightIn(min = …) rather than fixed heights, so scaled text grows its container instead of being clipped. Try the app at the largest font size before shipping; it is the fastest accessibility test there is.

Testing it

Because UI tests read the semantics tree, they double as accessibility checks. This test finds the product card by the merged description a TalkBack user would hear — so it would fail if the description went missing:

fun `tapping a product reports which one`() {
    var tapped: Product? = null
    compose.setContent {
        PizzaTheme {
            MenuContent(UiState.Loaded(SampleData.catalogue), false, {}, {}, onProductClick = { tapped = it })
        }
    }

    // The card's merged description, exactly as TalkBack would announce it.
    compose.onNodeWithContentDescription("Pepperoni", substring = true).performClick()
    assertEquals("p-pepperoni", tapped?.id)
}

…and this one asserts the stepper exposes its value as a state description, the property TalkBack announces:

fun `the quantity stepper is one adjustable control for accessibility`() {
    var value = 2
    compose.setContent {
        PizzaTheme { QuantityStepper(value = value, onValueChange = { value = it }, label = "Quantity") }
    }

    compose.onNode(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "2"))
        .assertIsDisplayed()
    compose.onNodeWithContentDescription("Increase").performClick()
    assertEquals(3, value)
}

Beyond tests: Google's Accessibility Scanner app flags small targets and low contrast on any screen, and nothing replaces turning TalkBack on and ordering a pizza with your eyes closed. Do that once and most of this lesson will feel obvious.

Next

Those were two of the app's tests. The next lesson is all of them: what is worth testing in an Android app, where each kind of test runs, and how to test code that waits for time to pass.