Build for Everyone · Part 1 of 5
TL;DR Accessibility doesn’t start with exotic APIs. It starts with five properties every screen must have: meaningful labels, 48dp touch targets, readable contrast, meaning that survives without color, and a reading order that matches intent. Each one is cheap to get right on day one and expensive to retrofit later. This post shows the idiomatic Compose way to do each, plus the specific ways each one breaks in production.
The bug report that wasn’t a crash
Imagine a release that looks perfect on every dashboard. Crash-free sessions above 99.9%, ANRs flat, conversion steady. Two weeks later a support ticket arrives: “I can’t find how to pay.” The user relies on TalkBack. During a redesign, the checkout entry point became an Icon with Modifier.clickable and no description. TalkBack announced it as "Unlabeled, double-tap to activate." Nothing threw. No metric moved. A paying customer simply could not pay.
That’s the defining property of accessibility bugs: they fail silently, and only for the people least able to work around them.
The audience is wider than “screen reader users.” It includes people with low vision who run at 200% font scale, the roughly one in twelve men with some form of color-vision deficiency, people with tremors, and every user who has ever squinted at a phone in direct sunlight or used it one-handed on a crowded train.
There’s a business and legal side too. The European Accessibility Act has applied to many consumer-facing digital products and services in the EU since June 2025, accessibility claims against mobile apps are routine in the US, and Google Play’s pre-launch report already flags accessibility issues on your builds. But the strongest argument is the simplest: an app that some people cannot use is, for them, a broken app.
Here are the five rules that form the floor.
Rule 1: Every interactive element has a meaningful label
When a clickable node has no text, no contentDescription, and nothing merged in from its children, TalkBack has nothing meaningful to say. It falls back to the role and "Unlabeled," as in "Unlabeled, button." A label should describe what the thing is and its current state, not what it looks like.
Generic: “Cart.” Useful: “Shopping cart, 3 items.”
<!-- res/values/strings.xml -->
<string name="cd_cart_empty">Shopping cart, empty</string>
<plurals name="cd_cart_items">
<item quantity="one">Shopping cart, %d item</item>
<item quantity="other">Shopping cart, %d items</item>
</plurals>
@Composable
fun CartAction(itemCount: Int, onOpenCart: () -> Unit) {
// English ignores quantity="zero", so the empty state needs its own string.
val description = if (itemCount == 0) {
stringResource(R.string.cd_cart_empty)
} else {
pluralStringResource(R.plurals.cd_cart_items, itemCount, itemCount)
}
IconButton(onClick = onOpenCart) {
BadgedBox(
badge = {
if (itemCount > 0) {
// The icon label already carries the count. Without this, IconButton
// merges the badge text too: "Shopping cart, 3 items, 3".
Badge(modifier = Modifier.clearAndSetSemantics { }) {
Text(itemCount.toString())
}
}
},
) {
Icon(Icons.Outlined.ShoppingCart, contentDescription = description)
}
}
}
Three production failure modes worth knowing:
-
String concatenation.
"Cart, " + count + " items"ships English to your Arabic users and breaks pluralization in languages with more than two plural forms. Always go through resources. -
The silent
quantity="zero". In English, Android selects plural forms by grammatical category, not literal value, so azeroitem is never chosen. Teams discover this when TalkBack reads "Shopping cart, 0 items" in a locale they never tested. - Leaky merges. Clickable containers merge their children’s semantics. Anything visible inside, like badges, prices, or “NEW” chips, gets appended to the announcement unless you deliberately clear it.
Decorative vs informative images
If removing an image loses no information, it’s decorative. Pass null so TalkBack skips it entirely:
Image(
painter = painterResource(R.drawable.header_wave),
contentDescription = null, // pure ornament: skip it
modifier = Modifier.fillMaxWidth(),
)
If the image carries information, like a product photo, describe it. Prefer alt text from your backend, and fall back to something human rather than a filename:
AsyncImage( // Coil
model = product.imageUrl,
contentDescription = product.altText ?: product.name,
)
A related rule: an Icon sitting next to a Text inside a Button should have contentDescription = null. The text is already the label; describing the icon too produces "Download, Download."
Rule 2: Touch targets are at least 48dp × 48dp
Small targets punish users with tremors, limited dexterity, or large fingers, and anyone tapping while walking. The key insight is that the visual size and the touch size are independent. Your designer’s 18dp info glyph can stay 18dp; its hit area cannot.
Here’s the bug as it usually appears in a code review:
// ❌ 18dp visual AND 18dp touch area
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = stringResource(R.string.cd_more_info),
modifier = Modifier
.size(18.dp)
.clickable(onClick = onClick),
)
And the fix, which separates the two sizes explicitly:
@Composable
fun InlineInfoButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.sizeIn(minWidth = 48.dp, minHeight = 48.dp) // touch area
.clip(CircleShape) // 48dp circular ripple, like IconButton
.clickable(role = Role.Button, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = stringResource(R.string.cd_more_info),
modifier = Modifier.size(18.dp), // visual size
)
}
}
If you’re on Material 3, Modifier.minimumInteractiveComponentSize() does the same job and is what IconButton, Checkbox, and friends use internally. Prefer the stock components where they fit; reach for the modifier when you build your own.
Two subtler failure modes:
-
Modifier order shrinks hit areas.
Modifier.padding(12.dp).clickable { }makes the padding untappable, becauseclickableonly sees the space left after padding. If the padding should be tappable, putclickablefirst. -
“Dense mode” overrides. Teams squeezing toolbars sometimes provide a zero or unspecified value for
LocalMinimumInteractiveComponentSizehigh in the tree. That silently shrinks every Material control below it. If you see that override, treat it as a bug unless there's a documented reason.
Also watch spacing: two 48dp targets that touch edge to edge are technically compliant and still easy to mis-tap. Leave at least 8dp between adjacent actions.
Rule 3: Text has enough contrast to read
The WCAG thresholds are the de facto standard: 4.5:1 for normal text, 3:1 for large text (WCAG defines this as 18pt regular or 14pt bold, which is roughly 24sp and 18.7sp on Android), and 3:1 for meaningful non-text UI like input borders and icons.
The most useful thing a senior engineer can do here is turn contrast into a test against your theme tokens instead of a one-off check in a design tool. The math lives in ui-graphics already:
/** WCAG 2.x contrast ratio. Translucent foregrounds are flattened onto the background first. */
fun contrastRatio(foreground: Color, background: Color): Float {
val fg = foreground.compositeOver(background).luminance()
val bg = background.luminance()
val lighter = maxOf(fg, bg)
val darker = minOf(fg, bg)
return (lighter + 0.05f) / (darker + 0.05f)
}
import kotlin.test.Test
import kotlin.test.assertTrue
class ThemeContrastTest {
private fun ColorScheme.textPairs() = mapOf(
"onSurface/surface" to (onSurface to surface),
"onSurfaceVariant/surface" to (onSurfaceVariant to surface),
"onPrimary/primary" to (onPrimary to primary),
"error/surface" to (error to surface),
"secondaryText/surface" to (onSurface.copy(alpha = 0.7f) to surface),
)
@Test
fun textRolesMeetWcagAa() {
val failures = mapOf("light" to AppLightColors, "dark" to AppDarkColors)
.flatMap { (themeName, scheme) ->
scheme.textPairs().mapNotNull { (role, pair) ->
val ratio = contrastRatio(pair.first, pair.second)
if (ratio < 4.5f) "$themeName/$role = ${"%.2f".format(ratio)}:1" else null
}
}
assertTrue(failures.isEmpty(), "Contrast failures:\n${failures.joinToString("\n")}")
}
}
Note the compositeOver call. The most common real-world contrast regression isn't a bad palette; it's color.copy(alpha = 0.6f) used for "secondary" text. On a light surface it may scrape by. On a dark surface, the same alpha can drop well below 4.5:1. Measuring the raw color without compositing hides the problem completely.
Dark theme pitfalls, briefly:
- Brand colors tuned for white backgrounds often fail on dark surfaces. Give dark theme its own tonal values rather than reusing the light ones.
- Elevated surfaces in Material 3 dark theme are lighter than the base surface. Text that passes on
surfacemay fail onsurfaceContainerHighest, so test the containers you actually use. - Disabled controls are exempt from WCAG contrast, but disabled is not the same as “secondary.” Don’t use disabled styling for content users need to read.
For quick manual checks, Google’s Accessibility Scanner flags low-contrast text on a running screen, and Part 3 of this series automates the same check in instrumented tests.
Rule 4: Never let color carry meaning alone
If you convert your screen to grayscale and something stops making sense, you’re relying on color alone. Error states are the classic case: a red border that means “this field is invalid” is invisible to many color-blind users and meaningless to TalkBack.
Redundancy is the fix: color plus an icon plus text on screen, and a single, specific error for assistive tech.
<string name="error_with_prefix">Error: %1$s</string>
@Composable
fun EmailField(
value: String,
onValueChange: (String) -> Unit,
errorMessage: String?,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(stringResource(R.string.label_email)) },
isError = errorMessage != null,
trailingIcon = errorMessage?.let {
{ Icon(Icons.Filled.Error, contentDescription = null) } // the text explains it
},
supportingText = errorMessage?.let { message ->
{
Text(
text = stringResource(R.string.error_with_prefix, message),
// Visual only: the reason reaches TalkBack once, via error() below.
modifier = Modifier.clearAndSetSemantics { },
)
}
},
singleLine = true,
modifier = modifier.semantics {
// With isError = true, Material 3 attaches a generic, localized error
// description ("Invalid input" in English). Replace it with the real reason.
if (errorMessage != null) error(errorMessage)
},
)
}
Why clear the supporting text’s semantics? The field merges its decorations into one node, so if both the supporting text and error() carry the message, TalkBack reads it twice in a row. Pick one channel for speech. The error() property is the better one: TalkBack announces it as an error, and Part 3's tests can assert on it directly. Behavior varies across Material 3 and TalkBack versions, so confirm on a device that you hear the message exactly once.
The same principle applies well beyond forms:
- Status chips (“Shipped,” “Delayed,” “Cancelled”) need a text label, not just a green, amber, or red dot.
- Charts need direct labels or patterns, not a legend that maps five similar hues.
- Required fields need an asterisk and a “required” label in semantics, not only a colored label.
- Links in body text need an underline or weight change, not just a different color.
Rule 5: The reading order matches the visual intent
TalkBack users move through a screen element by element with swipes. Compose builds that order from the semantics tree and then sorts siblings geometrically: roughly top to bottom, then start to end, grouping elements that share a row. For simple Column and Row layouts, that's exactly what you want.
It goes wrong when your layout is organized in columns that should be read one at a time. A side-by-side plan comparison is the textbook case:
Out of the box, TalkBack reads row by row across both columns: “Basic, Pro, ₹149 a month, ₹399 a month…” The user hears two products shuffled together. The fix is to tell Compose each column is a unit:
@Composable
fun PlanComparison(basic: Plan, pro: Plan, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
PlanColumn(basic, Modifier.weight(1f).semantics { isTraversalGroup = true })
PlanColumn(pro, Modifier.weight(1f).semantics { isTraversalGroup = true })
}
}
For finer control within a group, traversalIndex reorders siblings (lower values come first; the default is 0f). A common case is an overlay action pinned to the top corner of a card. Geometry says read it first; intent says read the content first:
@Composable
fun DealCard(deal: Deal, onSave: () -> Unit) {
Box(Modifier.semantics { isTraversalGroup = true }) {
Image(
painter = painterResource(deal.imageRes),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
)
IconButton(
onClick = onSave,
modifier = Modifier
.align(Alignment.TopEnd)
.semantics { traversalIndex = 1f }, // after title and price
) {
Icon(
Icons.Outlined.FavoriteBorder,
contentDescription = stringResource(R.string.cd_save_deal, deal.title),
)
}
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text(deal.title, style = MaterialTheme.typography.titleMedium)
Text(deal.priceLabel)
}
}
}
When not to override. Traversal overrides are a patch, not a design tool. If you find yourself setting traversalIndex on half a screen, the layout structure itself is probably wrong, and restructuring will fix TalkBack, keyboard navigation, and future maintainers all at once. Overrides also don't survive refactors well: a stray index left behind after someone moves a composable creates order bugs nobody can explain.
A five-minute exercise
Do this today, on your own app:
- Enable TalkBack (Settings → Accessibility → TalkBack), or toggle it with the volume-key shortcut.
- In TalkBack’s developer settings, turn on Display speech output so you can read what it says.
- Open your busiest screen and swipe right through every element without looking at the layout.
- Write down every “Unlabeled,” every duplicated phrase, and every moment you lose track of where you are.
Most teams find at least one issue per screen the first time they do this. That list is your backlog.
Common mistakes
-
Repeating the role in the label. Setting
contentDescription = "Submit button"on aButtonthat already shows "Submit" produces "Submit button, Submit, Button." TalkBack announces the role itself. - Describing decoration. Labelling dividers, background shapes, and avatar placeholders forces users to swipe past noise on every visit.
- Trusting Material to cover everything. Stock components handle roles, states, and touch size, but they can’t invent a meaningful label for your icon, fix your theme’s contrast, or know that your columns should be read separately.
The baseline checklist
Print this, pin it next to your PR template, or turn each line into a review question.
Up next
These five rules cover screens built from standard components. But the moment you build your own, like a star rating, a custom slider, or a swipe carousel, TalkBack sees a pile of unrelated shapes. Part 2, *Making Custom Composables Speak*, walks through the Compose semantics tree and turns a five-icon rating widget into a single, operable control.
📱 Go Beyond Using Jetpack Compose
If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend “Mastering Jetpack Compose Internals”. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.
- 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)