Accessibility Pitfalls in Jetpack Compose That Pass Code Review
The Problem
I saw a Reddit quiz that highlighted “accessibility pitfalls in Compose that most devs only discover during a production audit.” This caught my attention because it points to a real problem: accessibility issues in Jetpack Compose are silent failures.
Your code compiles. Your tests pass. Your code reviewer approves the PR. But users with disabilities cannot use your app.
These issues don’t throw exceptions. They don’t crash the app. They just quietly exclude a portion of your users. Here are the most common pitfalls I’ve seen that slip through code review.
Pitfall 1: Missing Content Descriptions
The most common accessibility issue I see is contentDescription = null on interactive elements.
Icon( imageVector = Icons.Default.Close, contentDescription = null, // FAILS accessibility! modifier = Modifier.clickable { onClose() })When you set contentDescription = null, you’re telling TalkBack to skip this element entirely. For a close button, that means screen reader users have no way to know what it does.
Icon( imageVector = Icons.Default.Close, contentDescription = "Close dialog", // Meaningful description modifier = Modifier.clickable { onClose() })When Is null Acceptable?
null is only acceptable for purely decorative elements that don’t convey information or provide interaction.
Row { Icon( imageVector = Icons.Default.Person, contentDescription = null // Decorative, the text explains the content ) Text("User Profile") // This provides the context}The rule I follow: if an icon is interactive or provides unique information, it needs a content description. If it’s purely visual decoration next to explanatory text, null is fine.
Pitfall 2: Touch Targets Below 48dp
Material Design specifies a minimum touch target of 48dp for accessibility. Smaller targets are hard to tap for users with motor impairments.
IconButton( onClick = { }, modifier = Modifier.size(32.dp) // Too small!) { Icon(imageVector = Icons.Default.Add, contentDescription = "Add")}The fix is straightforward: ensure at least 48dp.
IconButton( onClick = { }, modifier = Modifier .size(48.dp) // Minimum accessible size .semantics { contentDescription = "Add item" }) { Icon(imageVector = Icons.Default.Add, contentDescription = null)}Note: IconButton from Material3 already applies a 40dp minimum, but explicit sizing ensures consistency. If you need visual padding without increasing the visible icon size:
IconButton( onClick = { }, modifier = Modifier .size(48.dp) // Touch target) { Icon( imageVector = Icons.Default.Add, contentDescription = "Add item", modifier = Modifier.size(24.dp) // Visual size )}Pitfall 3: Custom Clickables Without Proper Semantics
When you build custom interactive components, you need to provide more than just clickable. TalkBack needs to understand what the element is and what state it’s in.
Box( modifier = Modifier .clickable { onToggle() }) { Icon( imageVector = if (isChecked) Icons.Default.Check else Icons.Default.Close, contentDescription = null )}When a screen reader user focuses this element, they hear nothing useful. They don’t know it’s a checkbox, and they don’t know its state.
Box( modifier = Modifier .clickable { onToggle() } .semantics { contentDescription = if (isChecked) "Checked" else "Unchecked" stateDescription = if (isChecked) "Selected" else "Not selected" role = Role.Checkbox }) { Icon( imageVector = if (isChecked) Icons.Default.Check else Icons.Default.Close, contentDescription = null )}The role property tells TalkBack this is a checkbox, so it announces “Double tap to toggle.” The stateDescription announces whether it’s currently selected.
Available Roles
Compose provides several semantic roles:
| Role | Use Case |
|---|---|
Role.Button | Buttons, clickable cards |
Role.Checkbox | Toggleable checkboxes |
Role.RadioButton | Radio selection |
Role.Switch | On/off toggles |
Role.Tab | Tab navigation |
Role.Image | Static images |
Pitfall 4: Missing Heading Semantics
Screen reader users navigate by headings to understand page structure. Without proper heading semantics, they must linearly traverse every element.
Text( text = "Settings", style = MaterialTheme.typography.headlineMedium)This looks like a heading visually, but TalkBack treats it as plain text. Users cannot jump to it using heading navigation.
Text( text = "Settings", style = MaterialTheme.typography.headlineMedium, modifier = Modifier.semantics { heading() })With heading(), TalkBack announces “Heading, Settings” and users can navigate by heading level.
Pitfall 5: Not Merging Semantics for Composite Components
When you have a clickable row with multiple children, TalkBack announces each element separately. This creates a fragmented, confusing experience.
Row(modifier = Modifier.clickable { onItemClick() }) { Icon(imageVector = Icons.Default.Person, contentDescription = "User") Text("John Doe") Text("Software Engineer")}TalkBack announces: “User. John Doe. Software Engineer.” The user has to swipe through three items to understand this is one clickable card.
Row( modifier = Modifier .clickable { onItemClick() } .semantics(mergeDescendants = true) { contentDescription = "John Doe, Software Engineer" }) { Icon(imageVector = Icons.Default.Person, contentDescription = null) Text("John Doe") Text("Software Engineer")}Now TalkBack announces the entire card as one element: “John Doe, Software Engineer.” Much better.
When to Merge
- List items that act as a single unit
- Cards with a single action
- Any composite component where children shouldn’t be individually focusable
Pitfall 6: Live Regions Not Announced
When content changes dynamically, screen reader users need to know. Without live regions, error messages and status updates go unnoticed.
Text( text = errorMessage, color = MaterialTheme.colorScheme.error)When errorMessage changes, the visual appearance updates, but TalkBack doesn’t announce anything.
Text( text = errorMessage, color = MaterialTheme.colorScheme.error, modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite contentDescription = "Error: $errorMessage" })Live Region Modes
| Mode | Behavior |
|---|---|
LiveRegionMode.Polite | Announces when user finishes current interaction |
LiveRegionMode.Assertive | Interrupts current announcement immediately |
Use Polite for most cases. Assertive should be reserved for critical alerts like “Session expired” or “Error saving data.”
Testing Your Accessibility
Code review isn’t enough. Here’s how I test accessibility before releasing:
1. TalkBack Testing
Enable TalkBack in device settings (Settings > Accessibility > TalkBack) and navigate your app using only the screen reader. You’ll quickly discover missing descriptions and broken navigation.
2. Accessibility Scanner
Install Google’s Accessibility Scanner. It detects:
- Touch targets that are too small
- Missing content descriptions
- Low contrast text
- Clickable items that are too close together
3. Compose Testing
Compose provides accessibility assertions in UI tests:
@Testfun closeButton_hasContentDescription() { composeTestRule .onNodeWithContentDescription("Close dialog") .assertExists() .assertHasClickAction()}
@Testfun settingsHeading_hasHeadingSemantics() { composeTestRule .onNodeWithText("Settings") .assert(SemanticsMatcher.expectValue(SemanticsProperties.Heading, Unit))}Summary
Accessibility issues in Jetpack Compose are easy to miss. They don’t break builds or fail tests. But they do break the experience for users with disabilities.
The key pitfalls I covered:
| Pitfall | Problem | Fix |
|---|---|---|
| Missing content description | Screen readers skip the element | Add meaningful description for interactive elements |
| Small touch targets | Hard to tap for motor impairments | Minimum 48dp size |
| Missing semantics on custom components | No role or state announced | Add role, stateDescription, contentDescription |
| Missing heading semantics | Cannot navigate by heading | Add .semantics { heading() } |
| Unmerged semantics | Fragmented announcements | Use mergeDescendants = true |
| Missing live regions | Dynamic changes not announced | Add liveRegion = LiveRegionMode.Polite |
Senior developers don’t wait for production audits to catch these issues. They add semantics proactively, test with TalkBack, and run Accessibility Scanner before every release.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments