Skip to content

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.

Wrong: Missing Content Description
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.

Correct: Meaningful Content Description
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.

Acceptable null for Decorative Icons
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.

Wrong: Touch Target Too Small
IconButton(
onClick = { },
modifier = Modifier.size(32.dp) // Too small!
) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Add")
}

The fix is straightforward: ensure at least 48dp.

Correct: Minimum 48dp Touch Target
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:

Touch Target with Padding
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.

Wrong: Custom Checkbox Without Semantics
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.

Correct: Custom Checkbox with Proper Semantics
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:

RoleUse Case
Role.ButtonButtons, clickable cards
Role.CheckboxToggleable checkboxes
Role.RadioButtonRadio selection
Role.SwitchOn/off toggles
Role.TabTab navigation
Role.ImageStatic 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.

Wrong: No Heading Semantics
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.

Correct: Proper Heading Semantics
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.

Wrong: Each Element Announced Separately
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.

Correct: Merged Semantics
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.

Wrong: Error Not Announced
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error
)

When errorMessage changes, the visual appearance updates, but TalkBack doesn’t announce anything.

Correct: Live Region Announces Changes
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
contentDescription = "Error: $errorMessage"
}
)

Live Region Modes

ModeBehavior
LiveRegionMode.PoliteAnnounces when user finishes current interaction
LiveRegionMode.AssertiveInterrupts 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:

Accessibility Test Assertions
@Test
fun closeButton_hasContentDescription() {
composeTestRule
.onNodeWithContentDescription("Close dialog")
.assertExists()
.assertHasClickAction()
}
@Test
fun 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:

PitfallProblemFix
Missing content descriptionScreen readers skip the elementAdd meaningful description for interactive elements
Small touch targetsHard to tap for motor impairmentsMinimum 48dp size
Missing semantics on custom componentsNo role or state announcedAdd role, stateDescription, contentDescription
Missing heading semanticsCannot navigate by headingAdd .semantics { heading() }
Unmerged semanticsFragmented announcementsUse mergeDescendants = true
Missing live regionsDynamic changes not announcedAdd 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