One card, four announcements — merging is the fix
A composite component produces one semantics node per child by default, so a card becomes four swipes. mergeDescendants, clearAndSetSemantics and invisibleToUser control the shape of the tree.

Day 68 of 100. Yesterday's tree has a shape, and the shape is what decides whether your list takes twelve swipes to read or three.
The symptom
A product card that reads as four separate items:
Card(onClick = { openProduct(product.id) }) {
Column(Modifier.padding(16.dp)) {
AsyncImage(product.imageUrl, contentDescription = product.name)
Text(product.name, style = MaterialTheme.typography.titleMedium)
Text(product.price, style = MaterialTheme.typography.bodyMedium)
Text("${product.rating} stars", style = MaterialTheme.typography.labelSmall)
}
}
TalkBack announces the image, then the name, then the price, then the rating — four swipes for one product. A list of twenty products takes eighty swipes to skim.
Worse, none of those four nodes is the clickable one, so the user hears the content and then has to find the card itself to activate it.
Why the obvious fix fails
The obvious fix is to describe the card and hide the children:
Card(
onClick = { … },
modifier = Modifier.semantics {
contentDescription = "${product.name}, ${product.price}, ${product.rating} stars"
},
) { … }
This adds a fifth node rather than replacing the four. semantics { } contributes
properties to a node; it doesn't remove the descendants, so now the card announces its
description and the children still announce themselves.
You need to say something about the subtree, not just about the card.

The actual mechanism
Three tools, in increasing order of how much they take away.
mergeDescendants = true — collapse the subtree into one node, concatenating the
children's text:
Modifier.semantics(mergeDescendants = true) { }
The card becomes one node announcing "Wireless headphones, £79.99, 4.5 stars", and it's the clickable node, so activating it works from the same place.
Merging happens automatically wherever a component declares an action —
clickable, toggleable, selectable, and Material components built on them. That's
why a Button containing an icon and a label is already one node, and why Day 37's
toggleable row worked without any explicit merge.
clearAndSetSemantics { } — discard everything from the subtree and state something
new:
Modifier.clearAndSetSemantics {
contentDescription = "Rating: 4.5 out of 5"
}
Right when the concatenation would be wrong. A five-star row would merge to "star, star, star, star, star"; clearing and replacing it says the useful thing instead. It's the heavier tool, and it removes information you might have wanted, so reach for merge first.
Modifier.semantics { invisibleToUser() } — remove a single node while leaving its
siblings. For a decorative element inside an otherwise-meaningful subtree.
What merging does and doesn't concatenate
Worth knowing precisely, because the result surprises people.
Merging combines contentDescription and text from descendants, in layout order.
It does not merge nodes that themselves declare a merge — a nested Button inside a
merged card stays a separate node, because it has its own action and must remain
independently reachable.
That last rule is the one that saves you: a card with a "Add to basket" button inside merges the card's text but leaves the button as its own node. Both are reachable, and the button's own label isn't swallowed into the card's announcement.
If a nested clickable should be swallowed — a decorative tappable area — remove its action rather than fighting the merge.
Order matters, and it's layout order
The concatenation follows layout order, so this:
Column {
Text(product.price) // "£79.99"
Text(product.name) // "Wireless headphones"
}
announces "£79.99, Wireless headphones" — price first, which is not how anyone would say it. Reordering the composables fixes the announcement and changes the visual layout, which is often not acceptable.
When the visual order and the spoken order genuinely need to differ,
clearAndSetSemantics with an explicit description is the honest answer. Tomorrow's post
covers traversalIndex for the cases where the order needs changing without replacing the
content.
The list case
Putting it together for the most common structure in any app:
LazyColumn {
items(products, key = { it.id }) { product ->
Card(
onClick = { open(product.id) },
modifier = Modifier.semantics {
// merged automatically by onClick; add what the visuals imply
stateDescription = if (product.inBasket) "In basket" else null.orEmpty()
customActions = listOf(
CustomAccessibilityAction("Add to basket") { add(product); true },
CustomAccessibilityAction("Save for later") { save(product); true },
)
},
) { ProductContent(product) }
}
}
One node per product, its content in a sensible order, its swipe actions exposed as custom actions from Day 67. Twenty products, twenty swipes, every action reachable.
The testing consequence
Merging changes what your tests see, which is the other reason to care:
onNodeWithText("Wireless headphones") // fails on a merged card
onNodeWithText("Wireless headphones", useUnmergedTree = true) // finds it
The default test tree is the merged one, so a test looking for a child's text inside a
merged component won't find it. useUnmergedTree = true opts into the raw tree.
Which of the two you should use is a real question: asserting against the merged tree
tests what a user experiences, which is usually the more valuable assertion. Reaching for
useUnmergedTree to make a selector work is worth a moment's thought — it sometimes
means the test is checking an implementation detail.
How to prove it
Print both trees and compare:
composeRule.onRoot().printToLog("MERGED")
composeRule.onRoot(useUnmergedTree = true).printToLog("UNMERGED")
The merged dump should have roughly one node per thing a user would consider an item. If a card produces four nodes there, it isn't merging; if a five-star row produces one node saying "star star star star star", it needs clearing rather than merging.
On device, the swipe count is the metric. Skim a list with TalkBack and count — if reading ten items takes forty swipes, the tree is four times too detailed.
The testTag exception
One small thing that trips people once. testTag is normally not merged into a parent,
so a tag on a child inside a merged card isn't findable in the merged tree.
Modifier.semantics(mergeDescendants = true) {
testTagsAsResourceId = true // for UIAutomator / cross-framework tools
}
For ordinary Compose tests, the answer is to put the tag where you want the test to grab — on the merged node itself for "the card", or query the unmerged tree for a child. Adding tags to every child and then wondering why the selectors fail is the usual path here, and the tree dump from above resolves it in seconds.
What this generalizes to
The principle is the structure of the description should match the structure of the content, not the structure of the code. A card is one thing to a user and four composables to you, and the semantics tree is where that difference gets reconciled.
It's the same instinct as Day 50's AnnotatedString: a sentence is one thing even when
it's assembled from parts, and modelling it as its parts pushes the reassembly onto
whoever consumes it. Here the consumer is a person listening.
And as with that post, the payoff is not confined to the audience you built it for. Merging a card correctly gives a screen-reader user one clear announcement, gives your tests a single node to assert on, and gives Switch Access one target instead of four. One change, three beneficiaries.
Tomorrow, Day 69: traversal order — controlling the sequence, for the cases where layout order is the wrong order.
Day 68 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Merging and clearing semantics.