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.