DEV Community

Cover image for Part 1: Raising the Floor: Five Accessibility Rules Every Compose Screen Must Pass
Android Expert
Android Expert

Posted on Originally published at Medium

Part 1: Raising the Floor: Five Accessibility Rules Every Compose Screen Must Pass

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.

who-benefits

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>
Enter fullscreen mode Exit fullscreen mode
@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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Three production failure modes worth knowing:

  1. 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.
  2. The silent quantity="zero". In English, Android selects plural forms by grammatical category, not literal value, so a zero item is never chosen. Teams discover this when TalkBack reads "Shopping cart, 0 items" in a locale they never tested.
  3. 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(),
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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.

touch-target

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),
)
Enter fullscreen mode Exit fullscreen mode

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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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, because clickable only sees the space left after padding. If the padding should be tappable, put clickable first.
  • “Dense mode” overrides. Teams squeezing toolbars sometimes provide a zero or unspecified value for LocalMinimumInteractiveComponentSize high 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.

contrast-ratios

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)
}
Enter fullscreen mode Exit fullscreen mode
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")}")
    }
}
Enter fullscreen mode Exit fullscreen mode

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 surface may fail on surfaceContainerHighest, 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.

color-alone

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>
Enter fullscreen mode Exit fullscreen mode
@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)
        },
    )
}
Enter fullscreen mode Exit fullscreen mode

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:

reading-order

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 })
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Enable TalkBack (Settings → Accessibility → TalkBack), or toggle it with the volume-key shortcut.
  2. In TalkBack’s developer settings, turn on Display speech output so you can read what it says.
  3. Open your busiest screen and swipe right through every element without looking at the layout.
  4. 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 a Button that 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.

baseline-checklist

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.


Originally published on Medium.

Top comments (0)