| name | kmp-accessibility |
| description | Accessibility in Compose Multiplatform — semantics, focus order, contrast, dynamic type, and the places where Android TalkBack and iOS VoiceOver diverge. Use when building or auditing shared UI. |
KMP Accessibility
Instructions
Compose Multiplatform maps Modifier.semantics to Android's AccessibilityNodeInfo and iOS's UIAccessibility. Most concepts transfer, but a few traits need platform-aware handling.
1. Core semantics
IconButton(
onClick = onFavorite,
modifier = Modifier.semantics {
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites"
role = Role.Button
stateDescription = if (isFavorite) "Favorited" else "Not favorited"
},
) {
Icon(if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, contentDescription = null)
}
- Set
contentDescription = null on decorative icons inside a labelled parent.
- Use
stateDescription for toggle state — both TalkBack and VoiceOver read it.
- Prefer
Role.Button, Role.Switch, Role.Checkbox so the platform announces the control type.
2. Headings & grouping
Text(
"Account",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() },
)
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
contentDescription = "Battery: 84 percent, charging"
},
) {
Icon(Icons.Filled.BatteryChargingFull, contentDescription = null)
Text("84%")
}
mergeDescendants = true is the KMP equivalent of iOS isAccessibilityElement = true — essential for composite controls.
3. Touch target size
Platform minimums differ: Android 48dp, iOS 44pt. Use the larger:
Modifier.minimumInteractiveComponentSize()
Wrap tap targets that look smaller than the hit box:
Box(
modifier = Modifier
.size(48.dp)
.clickable(onClickLabel = "Dismiss") { onDismiss() }
.padding(12.dp),
) { Icon(Icons.Filled.Close, contentDescription = null) }
4. Dynamic type & contrast
- Support user font scale: avoid
Modifier.size(...) on text containers; use wrapContentHeight.
- Contrast: ensure
onSurface/onPrimary token pairs meet 4.5:1 for text. Material 3 defaults comply, but branded themes often don't — verify with tooling.
- iOS has Bold Text and Increase Contrast system settings; these do not automatically propagate to Compose MP. Observe via
UIAccessibilityIsBoldTextEnabled() in iosMain and expose as a StateFlow<AccessibilityPrefs>.
5. Focus & traversal
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = email, onValueChange = onEmailChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
For custom traversal order, use Modifier.semantics { traversalIndex = 1f } — supported on both Android and iOS in CMP 1.7+.
6. Live regions & announcements
val announcer = LocalAccessibilityAnnouncer.current
LaunchedEffect(message) { message?.let { announcer.announce(it) } }
Back it with platform implementations:
class AndroidAnnouncer(private val view: View) : AccessibilityAnnouncer {
override fun announce(text: String) = view.announceForAccessibility(text)
}
class IosAnnouncer : AccessibilityAnnouncer {
override fun announce(text: String) =
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, text)
}
7. Testing
- Android:
accessibility-test-framework + Espresso.
- iOS:
XCUIElement.isAccessibilityElement assertions in XCTest, driven against the Compose view controller.
- Shared: semantic assertions via
composeTestRule.onNodeWithContentDescription("…") work on every target.
Checklist