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.

6 min read
androidcomposekotlinaccessibility

Day 67 — Compose builds a second tree, and users hear it

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.

Compose emits a layout tree and a parallel semantics tree; the second is what non-visual clients read

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 frameworkonNodeWithText, onNodeWithTag, every assertion.
  • Autofill and Compose's own tooling — Day 54's contentType lives 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.