
A pit board goes past the driver once a lap. It’s held out for about a second, at the end of a straight, at a closing speed no one would call comfortable. The crew gets one chance to put the right thing on it, usually the gap, the lap count or a single word. Teams spend real time on what goes on that board, because the driver cannot ask a follow-up question. He reads it or he doesn’t.
Nobody paints the whole race strategy on it. The board carries what the driver needs, in the order he needs it.
A screen reader user gets the same deal from your app. One swipe, one announcement, no way to glance at the rest of the screen. What you put on the board is what they get.
It’s easy to assume that because Compose draws everything itself, accessibility is somebody else’s problem, or at least a later one. It isn’t, and the quickest way to find out is to try it.
Turn on TalkBack on a real device and swipe through a list screen built the way most of us build them. Each row has a thumbnail, a title and a subtitle, the tap is handled by a gesture detector, and there’s an icon-only favourite toggle on the right. The title and the subtitle come out as separate stops. The row itself has no action and no name. The favourite toggle, if nobody gave it a description, is announced as an unlabelled “button”. A screen a sighted user takes in at a glance becomes a long series of swipes that never say what anything does.
Switch to VoiceOver on the iOS build and you hear the same gaps, because they come from the same shared code.
Compose Multiplatform has accessibility support. Nobody told it what anything was.
Semantics is the board
Compose doesn’t hand the system a tree of native views. It builds a parallel tree, the semantics tree, and that is what TalkBack and VoiceOver read. Every node can carry a role, a label, a state, an action. If you don’t set them, Compose infers what it can from the component. A Text exposes its text. A Button exposes a button role and its click action. Anything built from a bare Box and a gesture detector exposes much less than you think.
The first fix is the one you will use most. An icon with no text needs a description.
// commonMain
@Composable
fun FavouriteButton(isFavourite: Boolean, onToggle: () -> Unit) {
IconButton(onClick = onToggle) {
Icon(
imageVector = if (isFavourite) Icons.Filled.Star else Icons.Outlined.StarOutline,
contentDescription = if (isFavourite) {
stringResource(Res.string.remove_favourite)
} else {
stringResource(Res.string.add_favourite)
},
)
}
}
The description changes with the state, so the user hears what pressing the button will do, not what the icon looks like. It also comes from stringResource, which means it is localised like any other string. A hardcoded English contentDescription on a Portuguese device is worse than none.
Decorative images get contentDescription = null. It looks lazy, but it tells the screen reader to skip that one.
One row, one announcement
The noisy list rows have two causes. The tap lives in a gesture detector that the semantics tree knows nothing about, and the pieces of the row are never grouped.
Moving the tap to clickable on the whole row fixes both. clickable merges its children into a single node, so the title and the subtitle become one announcement attached to one action.
// commonMain
val favouriteLabel = stringResource(Res.string.favourite)
Row(
modifier = Modifier
.clickable(
onClickLabel = stringResource(Res.string.open_details),
role = Role.Button,
onClick = onOpen,
)
.semantics {
if (isFavourite) stateDescription = favouriteLabel
},
) {
Thumbnail(item.imageUrl) // contentDescription = null
Column {
Text(item.title)
Text(item.subtitle)
}
FavouriteIcon(isFavourite) // not clickable, no separate focus stop
}
Now the row reads as one thing, with the title, the subtitle, the state and a hint that double-tap opens details. The label is resolved outside the semantics block because stringResource is a composable call and the block is not.
For a group that is not clickable, like a card that only displays information, Modifier.semantics(mergeDescendants = true) does the same merge without adding an action.
onClickLabel is the part people skip. Without it, the user hears “double tap to activate”. With it, they hear what activating does. “Open details” beats making them guess.
When the merged result is still wrong, clearAndSetSemantics throws away what the children say and replaces it with exactly what you write. I use it sparingly. It is the override, and an override you reach for often is a sign the structure underneath is wrong.
Headings, roles and state
Screen reader users navigate by structure. They jump between headings the way a sighted user scans for large text. Compose will not guess which of your Text composables is a section title, so you have to mark it.
// commonMain
Text(
text = stringResource(Res.string.recent_activity),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
The same applies to custom controls. A custom toggle built from a Box has no idea it is a switch. Use Modifier.toggleable or Modifier.selectable with the right Role, and the system gets the role and the on/off state without any manual semantics.
// commonMain
Row(
modifier = Modifier.toggleable(
value = checked,
onValueChange = onCheckedChange,
role = Role.Switch,
),
) {
Text(stringResource(Res.string.notifications))
Switch(checked = checked, onCheckedChange = null)
}
Passing null to the inner Switch is deliberate. The row owns the interaction. If both handle it, the user gets two focus stops that do the same thing.
Touch targets and text size
Semantics can’t fix the next two.
A small tap target is a small tap target for everyone. Material components enforce a minimum interactive size of 48dp through minimumInteractiveComponentSize. Custom components do not inherit it. An icon drawn at 24dp inside a clickable is a 24dp target. Add the modifier, or pad the hit area, and the problem goes away on both platforms.
Text has to scale. Use sp for text and test at the largest font size the system allows. Layouts that clip, overlap or truncate at 200% are one of the most common failures, mostly because we all test at 100%. On iOS, Compose Multiplatform takes the system text size setting into account through the font scale, so the same sp rule carries over. The scaling curve is not guaranteed to match native UIKit text step for step (nonlinear font scaling was one of the Jetpack Compose features listed as not yet ported when iOS accessibility landed in 1.6.0), so check it on a device instead of assuming.
In practice, put the largest text size on a device, open every screen and look. It takes ten minutes and finds more than any lint rule.
What actually runs on iOS
Compose Multiplatform on iOS maps the semantics tree to native accessibility objects, so VoiceOver reads the same contentDescription, role and state you set in commonMain. You write the semantics once. VoiceOver support arrived in 1.6.0, and since 1.8.0 the accessibility tree is synchronised lazily, with no extra configuration on the ComposeUIViewController. Properties map across where iOS has an equivalent. testTag, for example, becomes the native accessibilityIdentifier, which is handy for XCUITest.
It is not identical, though. A role that TalkBack announces as “button” may be phrased differently by VoiceOver, and focus order can differ when layouts are complex. I treat the shared semantics as the baseline and the on-device check as the real test. It’s the rule I apply to everything else in KMP. Shared code gets you most of the way, and a physical device tells you the rest.
Desktop is a different story. Screen readers work on macOS. On Windows they go through the Java Access Bridge, which is disabled by default (jabswitch /enable for development, and the jdk.accessibility module in nativeDistributions for the packaged app). Linux is not supported. If you ship a desktop build, test it on each OS before you call it accessible.
Test it where it breaks
Semantics are testable, and that is the part that stops them rotting. If you already run runComposeUiTest from the earlier piece on UI testing in Compose Multiplatform, you can assert on what a screen reader would hear.
// commonTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.v2.runComposeUiTest
@OptIn(ExperimentalTestApi::class)
@Test
fun favouriteButton_announcesAction() = runComposeUiTest {
setContent { FavouriteButton(isFavourite = false, onToggle = {}) }
onNodeWithContentDescription("Add to favourites")
.assertHasClickAction()
}
Finding a node by its content description is already a check that the description exists. If a refactor drops the label, the test fails before a user finds out. As in the testing article, use the v2 import, because from Compose Multiplatform 1.11 the old runComposeUiTest is deprecated on non-Android targets.
Tests will not tell you whether the announcement makes sense. Only listening will. Turn on TalkBack and VoiceOver once per release and swipe through the three screens you changed. Do it with your eyes closed if you can bear it.
The line I hold
Accessibility isn’t something you bolt on at the end, and Compose Multiplatform isn’t the one to blame when it’s missing. It’s the same discipline as naming, where you say what a thing is and what it does. A button with no label is an unnamed variable.
A missing contentDescription on an interactive element should be treated the way you treat a missing null check. It’s a bug, and it gets caught in review instead of waiting on a polish list. If you ship on two platforms from one codebase, you ship the same accessibility gaps on both, and you only have to fix them once.
One board, one second, one message. Make it count. 🏁
The KMP Bits app is available on App Store and Google Play — built entirely with KMP.
Comments
Loading comments...