Compose builds a second tree, and it's the one your users hear
Every Compose UI produces a semantics tree alongside the layout tree. It's what screen readers read, what tests query, and what autofill inspects — so getting it right serves three audiences at once.

Day 67 of 100, opening the accessibility pillar. Compose produces two trees, and most developers only ever think about one of them.
The symptom
A custom rating control that a screen reader can't describe:
Row {
repeat(5) { index ->
Icon(
imageVector = if (index < rating) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = null,
modifier = Modifier.clickable { onRatingChange(index + 1) },
)
}
}
Visually it's a five-star rating. To TalkBack it's five unlabelled buttons in a row, announced as "button, button, button, button, button" — no indication of what they do, no indication of the current rating, and no way to know which are filled.
The control works and is unusable without sight.
Why the obvious fix fails
The obvious fix is to describe each icon:
Icon(
…,
contentDescription = if (index < rating) "Filled star" else "Empty star",
)
Now it announces "filled star, filled star, filled star, empty star, empty star" — five separate announcements the user must assemble into "3 out of 5". Worse, it's still five nodes to swipe through for one piece of information, on a screen that may have twenty other controls.
Describing the pixels isn't the job. Describing the meaning is.

The actual mechanism
Alongside the layout tree, Compose builds a semantics tree — a parallel structure of nodes carrying meaning rather than geometry. Three clients read it:
- Accessibility services — TalkBack, Switch Access, Voice Access.
- The test framework —
onNodeWithText,onNodeWithTag, every assertion. - Autofill and Compose's own tooling — Day 54's
contentTypelives here.
That's the framing that makes this pillar worth caring about even if accessibility isn't on your roadmap: you already depend on the semantics tree, because your tests query it. A screen that's hard to describe is a screen that's hard to test, and the two problems have one fix.
The rating control becomes one node with meaning:
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
contentDescription = "Rating: $rating out of 5"
role = Role.Button
}
) {
repeat(5) { index ->
Icon(
imageVector = if (index < rating) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = null,
modifier = Modifier.clickable { onRatingChange(index + 1) },
)
}
}
One announcement, the actual information, one swipe.
The properties worth knowing
Semantics is a set of key/value properties on a node. The ones that come up constantly:
Modifier.semantics {
contentDescription = "Play" // what it is, when no text says so
role = Role.Button // how to announce the type
stateDescription = "Playing" // the current state, in words
disabled() // announce as unavailable
heading() // a navigation landmark
liveRegion = LiveRegionMode.Polite // announce changes automatically
testTag = "play-button" // for tests only
}
Two distinctions that resolve most confusion:
contentDescription versus text. A Text composable already contributes its
string. Adding a contentDescription on top replaces it, so a labelled button described
again is announced only by the description — which is how a button ends up announcing
something different from what it says.
contentDescription versus stateDescription. The first is what the thing is; the
second is what state it's in. A switch's description is "Dark mode"; its state
description is "on". Merging them into one string means the state can't be announced
separately when it changes.
Actions, not just labels
Semantics can expose operations, which is what lets a screen-reader user do something the gesture would normally require:
Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Archive") { onArchive(); true },
CustomAccessibilityAction("Delete") { onDelete(); true },
)
}
A row with swipe-to-archive is unusable by someone who can't perform a swipe. Custom actions surface those operations in TalkBack's action menu — and they're the single highest-value accessibility addition to a list-based app, because swipe actions are otherwise entirely invisible.
The same applies to long-press menus and drag handles: if a gesture is the only path to a function, that function needs a semantic action too. That is a rule with no exceptions worth remembering: every gesture-only affordance is inaccessible until it has a semantic equivalent.
Where the built-ins already do this
Most Material components carry correct semantics already. Button sets Role.Button,
Switch sets its role and state, TextField wires its label. Day 37's toggleable on a
row merges the label and control into one node.
Which means the work concentrates in exactly two places: custom controls built from primitives, and icon-only buttons where no text exists to announce. If your app is mostly Material components with text labels, the accessibility gap is usually smaller than feared — and concentrated where the custom work is.
The decorative case
contentDescription = null is a real answer, not a shortcut. It means "this conveys
nothing; skip it", and it's correct for:
- A background texture or ornament.
- An icon beside text that already says the same thing.
- A chevron indicating a row is tappable, when the row itself announces its action.
Announcing all three would triple the swipes needed to read a list with no information gained. Silence is the right output for decoration, and choosing it deliberately is part of the job.
How to prove it
The direct check takes two minutes and is worth doing on every screen you build:
Turn on TalkBack, swipe through the screen, and listen. Three questions: does every interactive element announce what it does; is any information available only visually; and does the swipe count match the amount of information?
For automation, the semantics tree can be printed:
composeRule.onRoot().printToLog("SEMANTICS")
That dump is the fastest way to see what the tree actually contains — including the unlabelled nodes and the accidental duplication. It's also the tree your test selectors run against, so reading it once explains a lot of otherwise-mysterious test failures.
What this generalizes to
The idea is the interface has more than one rendering. Pixels are one; a description of meaning is another; a test's query surface is a third. They come from the same source, and a component that only produces the first is incomplete rather than merely inaccessible.
That reframing is worth carrying: this is not a separate accessibility task bolted on at the end, it's the same information the tests need, expressed once. Teams that treat it as one job get both; teams that treat it as two usually get neither.
The web reached the same conclusion by a longer route. The accessibility tree there is
derived from HTML that already carries meaning — a <button> is a button — which is why
<div onclick> is the canonical accessibility mistake: it renders correctly and describes
nothing. Compose starts from the opposite end, with a Box that draws and says nothing,
and asks you to add the meaning. Neither approach is automatic; both reward describing
what a thing is rather than what it looks like.
Tomorrow, Day 68: merging and clearing — controlling how many nodes a component produces, and why one node is usually right.
Day 67 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Semantics in Compose.