Why Accessibility Is Not Optional
Semantics: How Compose Communicates with Accessibility Services
// Custom icon button needs explicit semantics
@Composable
fun FavoriteButton(
isFavorite: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
onClick = onClick,
modifier = modifier.semantics {
contentDescription = if (isFavorite)
"Remove from favorites"
else "Add to favorites"
role = Role.Button
stateDescription = if (isFavorite)
"Favorited" else "Not favorited"
},
) {
Icon(
imageVector = if (isFavorite)
Icons.Filled.Favorite
else Icons.Outlined.FavoriteBorder,
contentDescription = null, // Handled by parent
tint = if (isFavorite)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Decorative images should be hidden from TalkBack
@Composable
fun DecorativeWave(modifier: Modifier = Modifier) {
Image(
painter = painterResource(R.drawable.wave_bg),
contentDescription = null, // null hides from a11y
modifier = modifier.semantics {
invisibleToUser()
},
)
}Touch Target Sizes and Spacing
// Too small: 24dp icon with no padding
// TalkBack users and users with motor impairments
// will struggle to tap this reliably
@Composable
fun SmallCloseButton(onClick: () -> Unit) {
// BAD: Visual and touch target are both 24dp
Icon(
Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier
.size(24.dp)
.clickable { onClick() },
)
}
// Fixed: Visual is 24dp, touch target is 48dp
@Composable
fun AccessibleCloseButton(onClick: () -> Unit) {
IconButton(
onClick = onClick,
modifier = Modifier.minimumInteractiveComponentSize(),
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.size(24.dp),
)
}
}
// For rows of items, ensure adequate spacing
@Composable
fun ActionRow(
onEdit: () -> Unit,
onDelete: () -> Unit,
onShare: () -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit,
contentDescription = "Edit")
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete,
contentDescription = "Delete")
}
IconButton(onClick = onShare) {
Icon(Icons.Default.Share,
contentDescription = "Share")
}
}
}Heading Hierarchy and Screen Structure
@Composable
fun ProfileScreen(user: User) {
LazyColumn {
// Screen title -- heading level
item {
Text(
text = "Profile",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(16.dp)
.semantics { heading() },
)
}
// Section heading
item {
Text(
text = "Personal Information",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.semantics { heading() },
)
}
item { ProfileField("Name", user.name) }
item { ProfileField("Email", user.email) }
// Another section heading
item {
Text(
text = "Preferences",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.semantics { heading() },
)
}
item {
SwitchPreference(
label = "Dark mode",
checked = user.darkMode,
onCheckedChange = { /* toggle */ },
)
}
}
}Focus Management and Keyboard Navigation
@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 errorFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
// Move focus to error message when it appears
LaunchedEffect(error) {
if (error != null) {
errorFocusRequester.requestFocus()
}
}
Column(modifier = Modifier.padding(16.dp)) {
// Error announcement
error?.let { msg ->
Text(
text = msg,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.focusRequester(errorFocusRequester)
.semantics {
liveRegion = LiveRegionMode.Assertive
},
)
}
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = {
passwordFocusRequester.requestFocus()
},
),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation =
PasswordVisualTransformation(),
modifier = Modifier.focusRequester(
passwordFocusRequester
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onSubmit(email, password) },
),
)
Button(
onClick = { onSubmit(email, password) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Sign In")
}
}
}Automated Accessibility Testing
@RunWith(AndroidJUnit4::class)
class AccessibilityTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun allButtons_haveContentDescription() {
composeRule.setContent {
MyAppTheme { HomeScreen() }
}
// Find all clickable nodes
composeRule
.onAllNodes(hasClickAction())
.fetchSemanticsNodes()
.forEach { node ->
val desc = node.config.getOrNull(
SemanticsProperties.ContentDescription
)
val text = node.config.getOrNull(
SemanticsProperties.Text
)
// Every clickable must have either
// contentDescription or visible text
assertTrue(
"Clickable node missing accessible label",
desc != null || text != null,
)
}
}
@Test
fun touchTargets_meetMinimumSize() {
composeRule.setContent {
MyAppTheme { HomeScreen() }
}
composeRule
.onAllNodes(hasClickAction())
.fetchSemanticsNodes()
.forEach { node ->
val bounds = node.boundsInRoot
val width = bounds.width
val height = bounds.height
assertTrue(
"Touch target too small:" +
" ${width}x${height}dp",
width >= 44f && height >= 44f,
)
}
}
}Key Takeaways
- 1Set contentDescription, role, and stateDescription on every custom interactive composable.
- 2Use Modifier.minimumInteractiveComponentSize() to ensure 48dp touch targets without changing visual size.
- 3Mark section titles with Modifier.semantics { heading() } so TalkBack users can navigate by headings.
- 4Manage focus programmatically: move focus to errors, trap focus in dialogs, support keyboard ImeAction flow.
- 5Run automated accessibility tests in CI to catch missing labels and undersized touch targets.
- 6Material 3 components have accessibility built in -- the work is making sure custom components match.
Frequently Asked
How do I test accessibility?
Enable TalkBack and navigate your app. Use Accessibility Scanner app for automated suggestions. Test with switch control and keyboard navigation. Include users with disabilities in testing.
What's the most common accessibility mistake?
Missing content descriptions on icons and images. Screen reader users hear 'unlabeled button' instead of meaningful descriptions.
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.