Composing with Accessibility in Mind: Because Every User Matters! — Part 1

Building Inclusive Jetpack Compose Apps for Everyone

6 min readMar 3, 2025

--

Have you ever wondered what makes an app truly the best? A great app isn’t just about beautiful UI, smooth UX, strong security, and ease of use, one of the most important aspects is accessibility. An app should work seamlessly for all users, including those with disabilities.

Recently, I had the opportunity to meet people with disabilities, and their stories deeply moved me. They rely on assistive technologies like TalkBack, Switch Access, and voice control to navigate the digital world. While these tools have transformed their ability to interact with apps, they also shared the challenges they still face, such as apps with missing accessibility labels, poor contrast, or interactions that are difficult to navigate without touch.

Hearing their experiences made me realize the immense power we, as developers, hold. With every line of code we write, we have the ability to make someone’s life easier, to remove barriers, and to create a more inclusive digital world.

Google is actively working to make Android apps more accessible, and Jetpack Compose provides powerful tools to help developers integrate accessibility seamlessly.

Android offers built-in accessibility features like:

  • TalkBack (screen reader)
  • Switch Access (for motor disabilities)
  • Voice Access (hands-free control)
  • Magnification & High Contrast (for low vision)
  • Live Caption & Sound Notifications (for hearing impairments)

By using these tools and following best accessibility practices, we can make apps easier to navigate, read, and use for everyone.

Key Accessibility Features in Jetpack Compose

contentDescription: Making Images and Icons Understandable

The contentDescription property provides a textual explanation of an image or icon so that screen readers like TalkBack can describe them to visually impaired users. When a screen reader encounters an image with a contentDescription, it reads out the description instead of just saying “image.” This ensures that users understand the meaning of the image.

For example,

// TalkBack will announce: "User profile picture"
Image(
painter = painterResource(id = R.drawable.profile),
contentDescription = "User profile picture"
)

// TalkBack will announce: "Play Video"
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Play video"
)

We can also add dynamic content description, which ensures accessibility for dynamic UI changes.

// TalkBack will say: "Muted" or "Unmuted", depending on the state.

val isMuted = remember { mutableStateOf(false) }

Image(
painter = painterResource(id = if (isMuted.value) R.drawable.mute else R.drawable.unmute),
contentDescription = if (isMuted.value) "Muted" else "Unmuted"
)

Some images or icons are only for decoration and don’t need to be read by screen readers. To hide them from accessibility services, set contentDescription to null.

For example: Background Image

Image(
painter = painterResource(id = R.drawable.background),
contentDescription = null, // Decorative background
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)

onClickLabel : Provides Action Clarity

When users interact with UI elements using assistive technologies like TalkBack, they need clear and meaningful descriptions of actions. Without proper labels, users may misinterpret button actions, leading to confusion.

Ambiguous Button Without onClickLabel

IconButton(onClick = { /* Refresh action */ }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}

📢 TalkBack announces: TalkBack will announce: 📢 “Refresh, Button”

🔴 Issue: what does “Refresh” actually mean? Will it refresh the current page? Will it reload data from the server? or Will it reset the form inputs?

The user doesn’t know the exact effect of clicking this button and so onCliCkLabel makes button actions clearer for screen reader users.

In Jetpack Compose, you can define onClickLabel in two ways:

  1. Using Modifier.semantics { onClick(label = “…”) }

This approach gives you more control over accessibility properties by explicitly defining how a UI element should behave. It is commenly used with IconButton, Button, or custom gestures.

IconButton(
onClick = { /* Favorite action */ },
modifier = Modifier.semantics {
onClick(label = "Reload the latest data") { /* Action */ true }
}
) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}

📢 TalkBack will now say: “Refresh, Button, Double tap to reload the latest data.”

Improvement: Now the user knows that clicking the button will fetch new data rather than just refreshing the UI.

2. Using Modifier.clickable(onClickLabel = “…”)

This method is more concise and ideal when using clickable() for standard interactions. It’s useful for simpler cases where clickable() is already part of the UI. Use with Icon, Text, Row, Column and other elements.

// TalkBack will say: "Bookmark, Button, Double tap to Add to bookmarks"

Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = "Bookmark",
modifier = Modifier
.clickable(
onClickLabel = "Add to bookmarks",
onClick = { /* Bookmark action */ }
)
.size(48.dp)
)

📢 TalkBack will now say: “Bookmark, Button, Double tap to Add to bookmarks.”

Consider Minimum Touch Target Sizes

The Android Accessibility Guidelines recommend ensuring a minimum touch target size of 48x48 dp for better usability. This ensures that users, including those with motor impairments, can easily tap on buttons, icons, and other interactive UI components without accidental misclicks.

However, this doesn’t mean that every button or clickable element needs to be visually 48 dp, but the tappable area should be. Common Misconception is that some developers think increasing the visual size using Modifier.size(48.dp) is the only way to meet this requirement, but that's not necessary. Instead, you can expand the touch target size while keeping the visual element smaller.

Use minimumTouchTargetSize() for Icons & Buttons

If an icon or button is smaller than 48dp, use minimumTouchTargetSize() to ensure it meets accessibility requirements:

Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Refresh",
modifier = Modifier
.clickable { /* Refresh action */ }
.semantics { onClick(label = "Reload latest data") { true } }
.minimumTouchTargetSize() // Expands tappable area to 48.dp
.size(24.dp) // Icon remains 24 dp
)

Now, even though the icon is visually 24 dp, the tappable area meets the 48x48 dp requirement!

Automatic Touch Target Expansion in Jetpack Compose

Any interactive element is smaller than minimum touch target size (48dp x 48dp), Compose automatically expands the touch target beyond its visual bounds.

Example: Small Button with Auto-expanded Touch Target

@Composable
fun SmallButtonExample() {
var clicked by remember { mutableStateOf(false) }

Box(
Modifier
.size(100.dp)
.background(if (clicked) Color.Green else Color.Red)
) {
Box(
Modifier
.align(Alignment.Center)
.clickable { clicked = !clicked }
.background(Color.Yellow)
.size(10.dp) // Very small clickable area
)
}
}
Small Button with Auto-expanded Touch Target Example

As you can see, even though the Yellow Box is just 10x10 dp, its touch target is automatically expanded to 48x48 dp. This means clicking around the Yellow Box (within 48dp) will still trigger the click event.

Solution: Use sizeIn() To Explicitly Set Minimum Touch Target

@Composable
fun AccessibleButtonExample() {
var clicked by remember { mutableStateOf(false) }

Box(
Modifier
.size(100.dp)
.background(if (clicked) Color.Green else Color.Red)
) {
Box(
Modifier
.align(Alignment.Center)
.clickable { clicked = !clicked }
.background(Color.Yellow)
.sizeIn(minWidth = 48.dp, minHeight = 48.dp) // Ensures proper touch target size
)
}
}
Button with sizeIn() Example

Here the Yellow Box is explicitly 48x48 dp, ensuring a proper touch target which prevents accidental overlaps between touch areas of different composables.

Lift Clickable Behavior to a Parent (For Selection Controls)

For components like Checkbox, RadioButton, or Switch, It’s often best to lift the clickable behavior to a parent container. This means setting the clickable callback on a parent Row or Box and making the component itself non-clickable (by setting its action parameter to null). This approach ensures that the entire area (which includes proper padding for touch targets) is interactive.

Row(
Modifier
.toggleable(
value = checked,
role = Role.Checkbox,
onValueChange = { checked = !checked }
)
.padding(16.dp)
.fillMaxWidth()
) {
Text("Enable feature", Modifier.weight(1f))
// The Checkbox is not directly clickable because its onCheckedChange is null,
// so the parent Row handles the toggle.
Checkbox(checked = checked, onCheckedChange = null)
}
Lift clicking behaviour to a parent example

Here the entire row (not just the checkbox) is now clickable, making it easier for users to tap.

Accessibility in Jetpack Compose is more than just adding labels or bigger touch targets, it’s about ensuring equal access to technology, and creating experiences that truly make a difference.

Check out the next part to explore advanced accessibility techniques in Jetpack Compose, including merging elements, navigating text, describing states, and adding custom actions.

Reference: https://developer.android.com/develop/ui/compose/accessibility

I hope this article was helpful. Thank you for reading!

Don’t forget to clap only if you think, I deserve it👏

If you have any queries related to Android, I’m always happy to help you. You can reach out to me on LinkedIn and Twitter.

Happy Learning🚀 Happy Coding📱

--

--

Bhoomi Vaghasiya Gadhiya
Bhoomi Vaghasiya Gadhiya

Written by Bhoomi Vaghasiya Gadhiya

Android Developer 📱 | Women Techmaker Ambassador | Enthusiastic about helping others 🤝 | | Let's code to create a better world! 🌟

Responses (1)