Why Accessibility Is Not Optional
Semantics: How Compose Communicates with Accessibility Services
Button is announced as a button without your help, and clickable and toggleable "merge their children's semantics by default, so that the component is treated as one logical entity".
What the framework cannot know is what a picture means. "When you define an Image or Icon composable, there is no automatic way for the Android framework to understand what the app is displaying. You need to pass a textual description of the graphic element." So the rule for graphics has two halves:
- Meaningful: set contentDescription to what the control does, in the user's language.
- Decorative: pass contentDescription = null. That is the documented way to say an image has no semantic meaning.
The earlier sample here added invisibleToUser() on top of a null description. The null was already enough, and that function is now deprecated: "Use hideFromAccessibility() instead" (reference). It also set role = Role.Button on an IconButton, which restates a default.// The description says what the control does in its current state.
// IconButton already supplies the button role and click action.
@Composable
fun FavoriteButton(
isFavorite: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(
imageVector = if (isFavorite)
Icons.Filled.Favorite
else Icons.Outlined.FavoriteBorder,
contentDescription = if (isFavorite)
stringResource(R.string.remove_from_favorites)
else stringResource(R.string.add_to_favorites),
)
}
}
// Decorative image: null means "no semantic meaning"
@Composable
fun DecorativeWave(modifier: Modifier = Modifier) {
Image(
painter = painterResource(R.drawable.wave_bg),
contentDescription = null,
modifier = modifier,
)
}
// A subtree that should not be reachable at all
Box(Modifier.semantics { hideFromAccessibility() }) {
/* purely visual duplicate of content announced elsewhere */
}What touch target size does Android require?
| Source | Criterion | Level | Minimum |
|---|---|---|---|
| WCAG 2.1 | 2.5.5 Target Size | AAA | "at least 44 by 44 CSS pixels" |
| WCAG 2.2 | 2.5.8 Target Size (Minimum) | AA | "at least 24 by 24 CSS pixels" |
| Android | Material guidance | n/a | 48dp |
Checkbox, RadioButton and Switch set the minimum size internally when they can receive user actions. And for a small element with clickable, "Compose still increases the touch target size. It does so by expanding the touch target size outside of the boundaries of the composable."
That second behaviour is a safety net, not a layout. The expanded area is invisible and can overlap a neighbour. For your own small controls, reserve the space with Modifier.minimumInteractiveComponentSize(), which "Reserves at least 48.dp in size to disambiguate touch interactions if the element would measure smaller" (reference). Put the size in the design system once and no screen has to remember it.// A 24dp icon made clickable. Compose expands the touch area
// beyond the bounds, but the layout still only reserves 24dp,
// so neighbours can sit inside the expanded area.
@Composable
fun SmallCloseButton(onClick: () -> Unit) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.close),
modifier = Modifier
.size(24.dp)
.clickable(onClick = onClick),
)
}
// Custom control: reserve 48dp in layout, keep the 24dp visual
@Composable
fun CloseChip(onClick: () -> Unit) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.minimumInteractiveComponentSize()
.clickable(
onClickLabel = stringResource(R.string.close),
role = Role.Button,
onClick = onClick,
),
) {
Icon(
Icons.Default.Close,
contentDescription = null, // the clickable carries the label
modifier = Modifier.size(24.dp),
)
}
}
// Where a Material control fits, use it and describe the icon
@Composable
fun AccessibleCloseButton(onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.close),
)
}
}Heading Hierarchy and Screen Structure
Modifier.semantics { heading() }. Mark the screen title and each section title. Do not mark text as a heading because it is large; mark it because content follows under it. On a wide screen, where adaptive layouts put two panes side by side, give each pane its own heading so the structure survives the change in layout.@Composable
fun ProfileScreen(user: User) {
LazyColumn {
item {
Text(
text = stringResource(R.string.profile),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(16.dp)
.semantics { heading() },
)
}
item { SectionHeading(stringResource(R.string.personal_information)) }
item { ProfileField(stringResource(R.string.name), user.name) }
item { ProfileField(stringResource(R.string.email), user.email) }
item { SectionHeading(stringResource(R.string.preferences)) }
item {
SwitchPreference(
label = stringResource(R.string.dark_mode),
checked = user.darkMode,
onCheckedChange = { /* toggle */ },
)
}
}
}
@Composable
private fun SectionHeading(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.semantics { heading() },
)
}Focus, Keyboards and Announcing Changes
FocusRequester, attach it with the focusRequester modifier, and "Call requestFocus() inside an event callback (such as onClick) or a coroutine scope."
A change the user did not cause, such as a validation error appearing, needs to be announced. That is a live region, and the mode matters. The guidance: "You should use liveRegionMode.Polite in most cases where users' attention should only briefly be drawn to alerts or important changing content on-screen. You should use liveRegion.Assertive sparingly to avoid disruptive feedback."
The earlier sample here marked a form error as assertive and tried to move focus onto a plain Text, which is not focusable. The version below announces the error politely and moves focus only between the fields that can take it.@Composable
fun LoginForm(onSubmit: (String, String) -> Unit) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val passwordFocus = remember { FocusRequester() }
Column(modifier = Modifier.padding(16.dp)) {
// Announced when it appears, without interrupting speech
error?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
},
)
}
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(R.string.email)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() },
),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.focusRequester(passwordFocus),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = { onSubmit(email, password) },
),
)
Button(
onClick = { onSubmit(email, password) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sign_in))
}
}
}Automated Accessibility Testing
boundsInRoot, which is in pixels, and compared it with 44. At a density of 3 a 24dp control is 72 pixels wide, so the test passed on exactly the controls it existed to catch. Use the framework's check and run it in CI on every pull request.// build.gradle.kts
// androidTestImplementation("androidx.compose.ui:ui-test-junit4-accessibility")
@RunWith(AndroidJUnit4::class)
class AccessibilityTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun homeScreen_passesAccessibilityChecks() {
composeTestRule.setContent {
MyAppTheme { HomeScreen() }
}
composeTestRule.enableAccessibilityChecks()
// Any action runs the checks. This one runs them on the root.
composeTestRule.onRoot().tryPerformAccessibilityChecks()
}
@Test
fun sectionTitles_areHeadings() {
composeTestRule.setContent {
MyAppTheme { ProfileScreen(testUser) }
}
composeTestRule
.onNodeWithText("Preferences")
.assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Heading))
}
}Key Takeaways
- 1Give every meaningful icon and image a contentDescription, and pass null for decorative ones.
- 2Android asks for 48dp touch targets. WCAG 2.1 44px is a AAA criterion and WCAG 2.2 AA asks for 24px.
- 3Reserve space for small custom controls with Modifier.minimumInteractiveComponentSize().
- 4Mark screen and section titles with Modifier.semantics { heading() } so services can jump between them.
- 5Announce changes with a polite live region. Keep assertive for time-sensitive alerts.
- 6From Compose 1.8.0, enableAccessibilityChecks() runs the Accessibility Test Framework inside your UI tests.
Frequently Asked
How do I test accessibility in a Compose app?
Do both kinds. Walk the main flows with TalkBack and a keyboard, because only a person can judge whether an announcement makes sense. Then add the ui-test-junit4-accessibility dependency and call enableAccessibilityChecks() in your Compose tests, which checks labels, contrast, touch target size and traversal order on every run.
What is the most common accessibility mistake in Compose?
A meaningful icon with no content description. Compose cannot know what a picture means, so an icon button without a description reaches a screen reader as an unlabelled control. The opposite mistake is nearly as common: describing decorative images, which makes the user listen to noise. Pass null for those.
Does WCAG require 44dp touch targets?
No. WCAG 2.1 criterion 2.5.5 asks for 44 by 44 CSS pixels at Level AAA, the strictest level. WCAG 2.2 added criterion 2.5.8 at Level AA with a 24 by 24 CSS pixel minimum. Android guidance is stricter than both and asks for 48dp, which is the number to build to.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.