Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 064 / 100

Accessibility in Compose for Android & Kotlin developers

Apps everyone can use.

Expona Admin10 min read

Making Your App Work for Everyone

Accessibility isn't an afterthought—it's a core responsibility when building Android apps. Whether your users have visual impairments, motor difficulties, or cognitive challenges, they deserve a smooth, usable experience. In this article, we'll explore accessibility in Compose for Android & Kotlin developers, covering the practical patterns and tools that let you build apps everyone can use.

Why Accessibility Matters in Production

Over one billion people worldwide have some form of disability. That's not a niche segment; it's a significant portion of your potential user base. Beyond the ethical imperative, accessibility also improves usability for everyone—larger touch targets help users on crowded buses, captions help people in noisy environments, and logical content hierarchy benefits users with cognitive load issues.

In our experience shipping e-commerce and SaaS apps, we've seen that accessibility work pays dividends. It forces you to think clearly about information hierarchy, state management, and user intent. Teams that prioritize accessibility tend to ship more robust, maintainable code overall.

contentDescription for Icons and Images

Every non-decorative image and icon needs a contentDescription. This is how screen readers like TalkBack know what to announce to users who can't see the visual element.

Consider a common e-commerce scenario: a product listing with a thumbnail, name, and price. The thumbnail isn't just decoration—it helps users identify products. Without a content description, TalkBack users have no idea what's in the image.

kotlin
// ❌ Missing contentDescription
Image(
    painter = painterResource(id = R.drawable.product_thumb),
    contentDescription = null,
    modifier = Modifier.size(80.dp)
)

// ✅ Proper contentDescription
Image(
    painter = painterResource(id = R.drawable.product_thumb),
    contentDescription = "Wireless headphones, blue, €45.99",
    modifier = Modifier.size(80.dp)
)

When writing content descriptions, be specific and concise. Include the object, relevant details (color, size, state), and context if it adds meaning. Avoid starting with "image of" or "icon of"—screen readers already announce that it's an image.

For decorative images (dividers, backgrounds, purely visual spacing), set contentDescription = null. This tells the accessibility framework to skip the element entirely, reducing noise for TalkBack users.

kotlin
// Decorative spacer—screen readers should ignore it
Image(
    painter = painterResource(id = R.drawable.decorative_divider),
    contentDescription = null,
    modifier = Modifier
        .fillMaxWidth()
        .height(2.dp)
)

For dynamic content, build the description based on state. A "like" button might have different descriptions depending on whether it's pressed:

kotlin
IconButton(
    onClick = { isLiked = !isLiked },
    modifier = Modifier.size(48.dp)
) {
    Icon(
        imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Outlined.Favorite,
        contentDescription = if (isLiked) "Unlike this product" else "Like this product",
        tint = if (isLiked) Color.Red else Color.Gray
    )
}

Minimum 48dp Touch Targets

Small buttons are frustrating for everyone, but they're impossible for users with motor impairments or those using devices with reduced precision (like people with tremors or arthritis). The Material Design guideline—enforced by Google Play—is a minimum of 48dp for touch targets.

This applies to interactive elements: buttons, checkboxes, switches, and icon buttons. In a ride-hailing app where users are confirming pickups or reporting issues, hitting a 32dp button while moving becomes a nightmare. A 48dp target is nearly twice as easy to hit.

kotlin
// ❌ Too small for reliable interaction
IconButton(
    onClick = { /* ... */ },
    modifier = Modifier.size(32.dp)
) {
    Icon(imageVector = Icons.Default.Close, contentDescription = "Close")
}

// ✅ Meets minimum 48dp guideline
IconButton(
    onClick = { /* ... */ },
    modifier = Modifier.size(48.dp)
) {
    Icon(imageVector = Icons.Default.Close, contentDescription = "Close")
}

When you have icon buttons with insufficient visual space, use minimumInteractiveComponentSize() from Material3. This extends the touch target without increasing the icon's visual size:

kotlin
IconButton(
    onClick = { /* ... */ }
    // Material3 automatically applies 48.dp minimumInteractiveComponentSize
) {
    Icon(imageVector = Icons.Default.Close, contentDescription = "Close")
}

For custom components, apply the minimum size constraint directly:

kotlin
Button(
    onClick = { /* ... */ },
    modifier = Modifier.size(
        width = 100.dp,
        height = 48.dp  // Minimum height
    )
) {
    Text("Next")
}

Be mindful of spacing between touch targets too. If buttons are only 4dp apart, a user might accidentally activate the wrong one. Aim for 8dp of separation where possible, or accept overlapping touch areas if that's unavoidable.

Semantics for Custom Components

When you build custom composables that go beyond standard Material components, you need to tell accessibility services what they are and how to interact with them. That's where semantics come in.

Imagine a custom rating component: five stars that users tap to set a rating. It's visual, intuitive, and completely inaccessible without semantic information.

kotlin
// ❌ Custom rating bar with no semantics
@Composable
fun RatingBar(
    rating: Int,
    onRatingChanged: (Int) -> Unit,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier) {
        repeat(5) { index ->
            Box(
                modifier = Modifier
                    .size(48.dp)
                    .clickable { onRatingChanged(index + 1) }
            ) {
                Icon(
                    imageVector = if (index < rating) Icons.Default.Star else Icons.Outlined.Star,
                    contentDescription = null
                )
            }
        }
    }
}

A TalkBack user navigates this component and hears nothing except maybe some tap feedback. They have no idea it's a rating control.

Now let's add semantics:

kotlin
// ✅ Rating bar with proper semantics
@Composable
fun RatingBar(
    rating: Int,
    onRatingChanged: (Int) -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier.semantics(mergeDescendants = true) {
            contentDescription = "Rating: $rating stars out of 5"
            // Allow increase/decrease actions
            if (rating < 5) {
                customAction("Increase rating") {
                    onRatingChanged(rating + 1)
                    true
                }
            }
            if (rating > 0) {
                customAction("Decrease rating") {
                    onRatingChanged(rating - 1)
                    true
                }
            }
        }
    ) {
        repeat(5) { index ->
            Box(
                modifier = Modifier
                    .size(48.dp)
                    .clickable { onRatingChanged(index + 1) }
            ) {
                Icon(
                    imageVector = if (index < rating) Icons.Default.Star else Icons.Outlined.Star,
                    contentDescription = null
                )
            }
        }
    }
}

For interactive custom components, you can define semantic actions that tell TalkBack what operations are available:

kotlin
// ✅ Slider-like custom component
@Composable
fun VolumeControl(
    volume: Float,
    onVolumeChanged: (Float) -> Unit,
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier
            .semantics {
                contentDescription = "Volume control"
                setProgress { targetValue ->
                    onVolumeChanged(targetValue)
                    true
                }
            }
            .draggable(
                state = rememberDraggableState { delta ->
                    val newVolume = (volume + delta / 100).coerceIn(0f, 1f)
                    onVolumeChanged(newVolume)
                }
            )
    ) {
        // Visual representation
    }
}

Use mergeDescendants = true when a group of small elements (like stars) should be treated as a single control. Without this, TalkBack would announce each star individually, which is verbose and confusing.

Test with TalkBack

No amount of semantic markup matters if you don't verify it works with actual screen readers. TalkBack is Android's built-in accessibility service, and testing with it reveals issues that static analysis can't catch.

Enable TalkBack on a real device or emulator: Settings → Accessibility → TalkBack → Enable. Interact with your app using only gestures and voice feedback. This quickly reveals missing descriptions, illogical navigation order, and confusing interaction patterns.

Key testing patterns:

  • Swipe right to navigate to the next element. Listen to what TalkBack announces.
  • Swipe left to go back.
  • Double-tap to activate the current element.
  • Two-finger swipe down to read from the current position to the bottom of the screen.

In an e-commerce app, navigate through a product listing. Does TalkBack announce the product name, price, rating, and availability? Or does it silently skip over them? Navigate to a "Add to cart" button—is it clear what it does?

Use the Accessibility Scanner tool from Google Play Services to get automated suggestions, but don't rely on it alone. Automated checks catch low-hanging fruit (missing content descriptions, small touch targets) but miss context-specific issues.

kotlin
// Example: Product card—test with TalkBack
@Composable
fun ProductCard(product: Product) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Image(
            painter = painterResource(id = product.imageRes),
            contentDescription = "${product.name}, €${product.price}",
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
        )
        Text(product.name, style = MaterialTheme.typography.headlineSmall)
        Text("€${product.price}", style = MaterialTheme.typography.bodyLarge)
        RatingBar(
            rating = product.rating,
            onRatingChanged = { /* ... */ }
        )
        Button(
            onClick = { /* Add to cart */ },
            modifier = Modifier
                .fillMaxWidth()
                .height(48.dp)
        ) {
            Text("Add to cart")
        }
    }
}

When testing, listen for:

  • Content descriptions that are clear and actionable.
  • Navigation that flows logically (left to right, top to bottom).
  • Interactive elements announced with their state (e.g., "Add to cart button" vs. "Added to cart button").
  • No silent elements that should have descriptions.

Common Mistakes

Setting contentDescription to the same value as visible text. If a button's label is already visible, you don't need a redundant description. TalkBack announces both the description and the label, resulting in duplicated announcements.

kotlin
// ❌ Redundant
Button(
    onClick = { /* ... */ },
    modifier = Modifier.semantics { contentDescription = "Submit form" }
) {
    Text("Submit")
}

// ✅ Let the Text handle the announcement
Button(onClick = { /* ... */ }) {
    Text("Submit")
}

Ignoring color contrast. While not strictly a Compose semantics issue, low contrast text is inaccessible to users with low vision. Aim for at least 4.5:1 contrast ratio for normal text, 3:1 for large text.

Building custom components without testing them. A visually appealing custom component is worthless if TalkBack can't navigate it or announce its purpose. Always test with real screen readers early.

Using generic descriptions. "Button" or "Image" is not helpful. "Delete account" or "Product photo: blue wireless headphones" is.

Forgetting about keyboard navigation. Not all users can or will use touch. Ensure your app is fully navigable with a keyboard. Compose handles this reasonably well out of the box, but custom components need explicit focus handling.

Key Takeaways

  • Every non-decorative image and icon needs a contentDescription. Make it specific and concise.
  • Maintain 48dp minimum touch targets for reliable interaction by all users, using minimumInteractiveComponentSize() when needed.
  • Custom components require semantics to communicate their purpose and available actions to screen readers.
  • Test with TalkBack early and often. Automated tools are helpful but can't replace real testing.
  • Accessibility improves the entire experience—clearer hierarchy, better state management, and more robust code benefit everyone.
  • Avoid common pitfalls like redundant descriptions, low contrast, and untested custom components.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles