Build for Everyone · Part 2 of 5
Previously: Part 1 (“Raising the Floor”) covered the five baseline rules every screen must pass. Those rules assume standard components. This part is about the ones you build yourself.
TL;DR Material components ship with semantics. Your custom components ship with none until you add them. The semantics tree, not the pixels, is what TalkBack reads. Making a custom control accessible comes down to four moves:
- Collapse it into one node: merge descendants for things people read, and clear and replace them for things people operate.
- Say what it is (
contentDescription) separately from what it currently holds (stateDescription). - Pick an interaction model that matches how the control behaves.
- Expose actions so nobody has to hit a 20dp star precisely.
The five-stars problem
Picture a recipe app with a “Rate this recipe” row: five stars, tap one to set your score. It looks great and works perfectly with a finger. Here’s the code, which you’ll find in some form in a lot of codebases:
// ❌ To TalkBack, this is five unrelated buttons
@Composable
fun NaiveRating(rating: Int, onRatingChange: (Int) -> Unit) {
Row {
for (star in 1..5) {
Icon(
painter = painterResource(
if (star <= rating) R.drawable.ic_star_filled else R.drawable.ic_star_outline,
),
contentDescription = "Star",
modifier = Modifier
.size(32.dp)
.clickable { onRatingChange(star) },
)
}
}
}
Now turn on TalkBack and swipe through it. You hear “Star, button” five times. You aren’t told which stars are filled, what the control is for, or what the current score is. If someone “fixed” the hard-coded string by passing null, it gets worse: five "Unlabeled, button" stops in a row.
What a screen reader user needs is one stop and one sentence: “Recipe rating, 3 of 5 stars, slider,” plus a way to change the value without hunting for a 32dp target.
Getting from the first version to the second takes three steps. First, a short look at the data structure all three steps edit.
Semantics 101: the tree TalkBack actually reads
As Compose builds your UI tree, it also builds a parallel semantics tree. That tree only has nodes where something carries meaning: text, a description, a role, a state, or an action someone can perform. TalkBack, Switch Access, Voice Access, autofill, and your UI tests all read this tree. None of them see your pixels.
The tree comes in two views:
- Unmerged is every semantics node exactly as each composable declared it.
-
Merged is what accessibility services work with. A node marked
mergeDescendants = trueabsorbs its descendants' properties and becomes a single focus stop.
Many stock components merge for you. Button, IconButton, ListItem, and any element using Modifier.clickable or Modifier.toggleable do this, which is why a Material button containing an icon and a label reads as one thing.
You can inspect both views in two ways:
- Layout Inspector in Android Studio. Select a composable and its attributes panel lists the semantics it declares and the semantics it ends up with after merging.
- A test or debug harness, which dumps the whole tree to Logcat:
composeTestRule.onRoot().printToLog("A11Y") // merged: what TalkBack sees
composeTestRule.onRoot(useUnmergedTree = true).printToLog("A11Y") // as each composable declared it
A useful rule of thumb: if a node in the merged tree doesn’t read like something you’d say out loud, TalkBack won’t say it well either.
Step 1: Collapse the control into one node
Merge things people read
Start with the read-only case: a review header with an avatar, the reviewer’s name, a star strip, and a timestamp. It should be one stop, not four.
<string name="rating_display">Rated %1$d out of %2$d</string>
private const val MAX_STARS = 5
@Composable
fun ReviewHeader(review: Review, modifier: Modifier = Modifier) {
// semantics { } is not a composable scope, so resolve strings before entering it.
val starsLabel = stringResource(R.string.rating_display, review.stars, MAX_STARS)
Row(
modifier = modifier.semantics(mergeDescendants = true) { },
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage( // Coil
model = review.authorPhotoUrl,
contentDescription = null, // decorative: the name sits right next to it
modifier = Modifier.size(40.dp).clip(CircleShape),
)
Column(Modifier.padding(start = 12.dp)) {
Text(review.authorName, style = MaterialTheme.typography.titleSmall)
StarStrip(
filled = review.stars,
modifier = Modifier.semantics { contentDescription = starsLabel },
)
Text(review.postedLabel, style = MaterialTheme.typography.bodySmall)
}
}
}
The star strip itself draws icons and nothing else:
@Composable
private fun StarStrip(filled: Int, modifier: Modifier = Modifier) {
Row(modifier) {
repeat(MAX_STARS) { index ->
Icon(
painter = painterResource(
if (index < filled) R.drawable.ic_star_filled else R.drawable.ic_star_outline,
),
contentDescription = null, // the strip speaks for all five
modifier = Modifier.size(16.dp),
)
}
}
}
Why do the child icons get null? Because merging collects. It doesn't summarize or remove duplicates. If each star kept a "Star" description, the merged node would carry the strip's label and five more stars, and TalkBack would read all of them. After merging, the parent is the only one who should speak.
The merged node ends up with the two Text values and the strip's content description, so TalkBack reaches the whole header in one stop. Merged nodes keep text and content descriptions as separate lists, and the way TalkBack joins them can differ slightly between versions. Listen to the final phrasing once on a device rather than assuming it.
Clear and replace for things people operate
Merging has a limit, and it shows up the moment the children are interactive. A child that is itself clickable, toggleable, or otherwise merging stays its own focus stop. Wrap NaiveRating in semantics(mergeDescendants = true) and nothing changes: TalkBack still finds five buttons.
When the visual parts of an input control mean nothing on their own, replace them. clearAndSetSemantics throws away every descendant's semantics and lets you declare the node's entire accessible identity. Touch input on the children keeps working exactly as before. Only what assistive technology sees changes.
Row(
modifier = Modifier.clearAndSetSemantics {
// Everything TalkBack knows about this control is declared here, and only here.
},
) {
// five tappable stars, invisible to accessibility services
}
The trade-off: you now own everything. Anything you don’t declare doesn’t exist. That includes the label, the value, the role, and every way to operate the control. Steps 2 and 3 fill those in.
Step 2: Say what it is, then what it holds
Two properties do two different jobs:
-
contentDescriptionis identity. It's stable for the life of the screen: "Recipe rating." -
stateDescriptionis the current value. It changes as the user acts: "Not rated," "3 of 5 stars."
It’s tempting to cram both into one string, "Recipe rating, 3 of 5 stars". Keep them apart instead, for three reasons:
- When the focused node’s state description changes, TalkBack can announce the new value without re-reading the label.
- Tests (Part 3) can assert on the value directly.
- Translators get two clean strings instead of one concatenation.
Choosing the interaction model
The role question is really a question about how the control behaves. For star ratings there are three honest options.
- Read-only display. One node with a description and no actions. Use this for averages on product cards. Don’t make it look tappable if it isn’t.
-
Radio group. Put
Modifier.selectableGroup()on the row andModifier.selectable(selected = ..., role = Role.RadioButton)on each star. This allows direct selection, but it costs five focus stops. It also creates a quiet mismatch: three stars look filled while only one is selected. Radio groups suit small sets of choices with no order, like S/M/L, much better than ordered scales. -
Adjustable value. One node that declares a range and a
setProgressaction. This is exactly how Material's ownSliderpresents itself: it sets no role at all, and Compose exposes it to Android as a seek bar. It gives users one stop, and it matches what a rating actually is, a point on an ordered scale.
For rating input, the adjustable model wins. Its one weakness is that adjust gestures are invisible to people who don’t already know them. Step 3 fixes that.
Modifier.clearAndSetSemantics {
contentDescription = label
stateDescription = stateText
progressBarRangeInfo = ProgressBarRangeInfo(
current = rating.toFloat(),
range = 0f..maxRating.toFloat(),
steps = maxRating - 1, // whole stars only: 1, 2, 3, 4 sit between the ends
)
setProgress { target ->
val next = target.roundToInt().coerceIn(0, maxRating)
if (next != rating) onRatingChange(next)
true
}
}
Three details here matter in production:
-
Without
stateDescription, the value is spoken as a percentage. Compose falls back to describing a range by its percentage, so a 3-star rating would come out as "60 percent." Your state description replaces that with the words people actually use. -
stepscontrols how far one adjustment moves the value. When a node exposes both a range andsetProgress, Compose also offers the platform's scroll-forward and scroll-backward actions. TalkBack's adjust gestures use those, moving by one step. Withsteps = maxRating - 1, one step is exactly one star. Leavestepsat0and the range is treated as continuous, so each adjustment moves by a small fraction of a star. -
setProgressmust snap. Assistive technology can request any float value. Round it and clamp it to the range yourself rather than trusting the caller.
Step 3: Custom actions, for people who can’t aim
Adjust gestures exist, but plenty of TalkBack users never learn them. Switch Access and Voice Access users depend on named actions. CustomAccessibilityAction adds named commands to the node, which TalkBack lists in its Actions menu and other services expose in their own way.
<string name="rating_action_increase">Increase rating</string>
<string name="rating_action_decrease">Decrease rating</string>
<string name="rating_action_clear">Clear rating</string>
customActions = buildList {
if (rating < maxRating) {
add(CustomAccessibilityAction(increaseLabel) { onRatingChange(rating + 1); true })
}
if (rating > 0) {
add(CustomAccessibilityAction(decreaseLabel) { onRatingChange(rating - 1); true })
add(CustomAccessibilityAction(clearLabel) { onRatingChange(0); true })
}
}
The lambda returns a Boolean. true means "handled." false tells the accessibility service the action didn't happen, and the service may give the user failure feedback. The better pattern, shown above, is to offer only the actions that can succeed right now. There's no "Increase rating" at five stars, so a user can never pick an action that silently does nothing.
This step isn’t polish. Once you’ve called clearAndSetSemantics, the individual stars are invisible to TalkBack. Exploring by touch lands on the whole control, not on star four. For anyone using assistive technology, adjust gestures and named actions are now the only ways to change the value. That's exactly why both must exist.
The stale-state trap
Here’s a bug that survives code review because the screen looks correct:
// ❌ Computed once on first composition, never again
val context = LocalContext.current
val stateText = remember { context.getString(R.string.rating_state, rating, maxRating) }
The user picks “Increase rating.” The parent updates its state, the stars repaint, and TalkBack still says “3 of 5 stars.” What the screen shows and what TalkBack says have drifted apart, and only screen reader users notice. Derive the description from current state on every composition (stringResource is cheap). If you really must cache it, key the cache: remember(rating, maxRating) { ... }.
A related version of the same bug: a caller passes onRatingChange = {} while wiring things up, the stars appear to work in a preview because the preview owns its own state, and the action does nothing in the real screen. Hoist the state, and test the action, not just the tap. Part 3 shows how.
Putting it together
Here’s the complete control. Every line maps to one of the steps above.
<string name="rating_state">%1$d of %2$d stars</string>
<string name="rating_state_none">Not rated</string>
<string name="label_recipe_rating">Recipe rating</string>
@Composable
fun RatingStars(
rating: Int,
onRatingChange: (Int) -> Unit,
label: String,
modifier: Modifier = Modifier,
maxRating: Int = 5,
enabled: Boolean = true,
) {
// Step 2: state is derived on every composition, never cached without a key.
val stateText = if (rating == 0) {
stringResource(R.string.rating_state_none)
} else {
stringResource(R.string.rating_state, rating, maxRating)
}
val increaseLabel = stringResource(R.string.rating_action_increase)
val decreaseLabel = stringResource(R.string.rating_action_decrease)
val clearLabel = stringResource(R.string.rating_action_clear)
Row(
// Step 1: one node; the stars below contribute nothing to semantics.
modifier = modifier.clearAndSetSemantics {
contentDescription = label
stateDescription = stateText
progressBarRangeInfo = ProgressBarRangeInfo(
current = rating.toFloat(),
range = 0f..maxRating.toFloat(),
steps = (maxRating - 1).coerceAtLeast(0),
)
if (enabled) {
setProgress { target ->
val next = target.roundToInt().coerceIn(0, maxRating)
if (next != rating) onRatingChange(next)
true
}
// Step 3: only actions that can succeed right now.
customActions = buildList {
if (rating < maxRating) {
add(CustomAccessibilityAction(increaseLabel) { onRatingChange(rating + 1); true })
}
if (rating > 0) {
add(CustomAccessibilityAction(decreaseLabel) { onRatingChange(rating - 1); true })
add(CustomAccessibilityAction(clearLabel) { onRatingChange(0); true })
}
}
} else {
disabled()
}
},
) {
for (star in 1..maxRating) {
val filled = star <= rating
Box(
modifier = Modifier
.size(48.dp) // Part 1, Rule 2: 48dp touch target per star
.clip(CircleShape)
.clickable(enabled = enabled) {
// Tapping the current top star again clears the rating.
onRatingChange(if (star == rating) 0 else star)
},
contentAlignment = Alignment.Center,
) {
Icon(
// Part 1, Rule 4: filled vs outline differ in shape, not only color.
painter = painterResource(
if (filled) R.drawable.ic_star_filled else R.drawable.ic_star_outline,
),
contentDescription = null,
tint = if (filled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(28.dp),
)
}
}
}
}
Using it looks like any other state-hoisted control:
var rating by rememberSaveable { mutableIntStateOf(0) }
RatingStars(
rating = rating,
onRatingChange = { rating = it },
label = stringResource(R.string.label_recipe_rating),
)
Before and after, as a TalkBack user experiences it:
- Before: five stops, each “Star, button.” The score is never spoken, and the control’s purpose is never stated.
- After, on focus: one stop, “Recipe rating, Not rated, slider,” followed by TalkBack’s usage hint.
- After an adjust gesture or “Increase rating”: “1 of 5 stars.”
- After “Clear rating” from the Actions menu: “Not rated.”
Exact wording, like whether the role is spoken as “slider” or “seek control,” depends on the TalkBack version. The structure is what matters.
Keyboard note. The stars are hidden from TalkBack, but their
clickablemodifiers still accept keyboard focus. On tablets and Chromebooks with a hardware keyboard, that means five Tab stops and no arrow-key adjustment. If keyboard users matter to your app, give the starsfocusProperties { canFocus = false }, make the row focusable, and handle left and right arrow keys there. Part 4 covers focus in depth.
A reusable pattern for any custom control
The rating control is just one example. The same five questions work for any custom component you build:
- What’s the unit? Merge things people read. Clear and replace things people operate.
-
What is it? A stable
contentDescription, or the label from a role-bearing modifier. -
What does it hold right now? A
stateDescription, plus the platform's own notion of state where one exists:toggleableState,selected, or a range. -
How is it operated without precision? Use built-in actions first (
onClick,setProgress, scrolling), then custom actions for everything else. - Does the spoken state change when the pixels do? If you can’t answer that with a test, you don’t know.
One more rule: reach for the semantic modifier before hand-rolling semantics. Modifier.toggleable(value, role = Role.Switch, onValueChange = ...) gives you the role, the on/off state, and the click action in one line. Write custom semantics only for what the stock modifiers can't express.
Common mistakes
-
Merging without silencing children. The parent gets a good label, but each child icon keeps its own description, so TalkBack reads the summary and then “Star, Star, Star, Star, Star.” After merging, children that are only visual get
contentDescription = null. -
Actions that change state but not the spoken state. This usually comes from a description cached with
remember { }and no key, or a callback that was never hoisted. The screen updates and the speech doesn't. -
A role that contradicts behavior.
Role.Buttonon an adjustable control makes TalkBack promise "double-tap to activate," and then nothing happens. Radio buttons on an ordered scale confuse which stars are "selected." If the role tells the user what gesture to use, that gesture must work. -
Clearing semantics and forgetting to put anything back. After
clearAndSetSemantics, a control with nosetProgress, noonClick, and no custom actions can be focused but never operated.
Up next
Every property we declared here (the content description, the state, the range, the actions) can be queried, which means it can be tested. Part 3, *Testing Accessibility*, turns this post into CI checks:
- asserting state descriptions
- invoking custom actions from a test
- enforcing 48dp targets
- letting Google’s Accessibility Test Framework catch what you forgot
It also covers what no automated check can catch, and why the manual TalkBack routine from Part 1 still earns its ten minutes per release.
🔐 Go Beyond Using Android — Master How It’s Actually Secured
If you’re building, testing, or breaking Android apps, understanding the security model beneath the SDK separates developers who follow best practices from those who truly understand why those practices exist. I highly recommend “Mastering Android Security: From Fundamentals to Advanced Practice.” It’s a comprehensive, architecture-first journey through app sandboxing, cryptography, network defense, reverse engineering, and exploitation — spanning everything from AndroidManifest.xml internals to TrustZone and the Verified Boot chain, with hands-on labs at every step.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published on Medium.






Top comments (0)