Accessibility (a11y) for Kotlin Multiplatform Compose — semantic roles and mergeDescendants, contentDescription on interactive and image elements, screen reader traversal order, minimum touch target size, Roborazzi accessibility snapshot tests, reduced-motion support, and a Compose a11y audit checklist.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ronjunevaldoz/kmp-agent-skills --skill kmp-compose-accessibility --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Kmp Compose Accessibility?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ronjunevaldoz-kmp-compose-accessibility)More formats (shields.io, HTML) on the badges page.
---
name: kmp-compose-accessibility
description: >-
Accessibility (a11y) for Kotlin Multiplatform Compose — semantic roles and
mergeDescendants, contentDescription on interactive and image elements, screen
reader traversal order, minimum touch target size, Roborazzi accessibility
snapshot tests, reduced-motion support, and a Compose a11y audit checklist.
license: Apache-2.0
metadata:
author: kmp-agent-skills
last-updated: '2026-08-24'
keywords:
- accessibility
- a11y
- contentDescription
- semantics
- screen reader
- TalkBack
- VoiceOver
- semantic role
- mergeDescendants
- touch target
- accessibility test
- Roborazzi a11y
- reduced motion
- WCAG
- compose accessibility
---
## When to Use This Skill
Use when:
- A screen must be navigable by TalkBack (Android) or VoiceOver (iOS)
- Interactive elements (buttons, links, custom composables) need meaningful labels
- Touch targets need to meet the 48×48dp minimum size requirement
- Adding accessibility snapshot tests alongside visual screenshots
- An existing screen is failing an a11y audit
**Trigger keywords:** accessibility, a11y, TalkBack, VoiceOver, contentDescription,
semantics, screen reader, semantic role, mergeDescendants, touch target, WCAG,
accessibility test, Roborazzi a11y, traversal order, live region, role = button,
accessibility label, semantic tree, accessible, screen reader support, accessible app,
a11y compliance, accessibility audit, accessible design, make accessible.
**Freshness rule:** Roborazzi a11y snapshot support (`captureRoboImage` with
`accessibilityCapture()`) was added in Roborazzi 1.7. The Compose
`semantics { }` API is stable; `ExperimentalComposeUiApi` attributes were promoted to
stable in Compose 1.6. WCAG 2.2 is the current standard — refer to SC 1.1.1, SC 2.4.3,
and SC 1.4.3 for the rules most relevant to mobile.
---
## Recommendation First
Accessibility should be built in, not bolted on. Follow these three rules for every screen:
1. Every interactive element has a `contentDescription` or `semantics { contentDescription }` label
2. Custom clickable composables declare `role = Role.Button` (or the appropriate role)
3. Non-decorative images have `contentDescription`; purely decorative images have `contentDescription = null`
Run the Compose a11y checker and Roborazzi a11y snapshot on every screen in CI.
---
## Content descriptions
```kotlin
// Interactive icon button — must have a label
IconButton(onClick = { onIntent(Intent.DeleteItem(item.id)) }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete ${item.name}", // describes action, not icon shape
)
}
// Decorative illustration — null = screen reader skips it
Image(
painter = painterResource(Res.drawable.img_success),
contentDescription = null, // decorative — no information loss if skipped
)
// Meaningful image
AsyncImage(
model = user.avatarUrl,
contentDescription = "${user.name}'s profile photo",
)
```
---
## Semantic roles on custom composables
```kotlin
// Custom clickable card — declare it as a button
@Composable
fun ProductCard(product: Product, onClick: () -> Unit) {
Box(
modifier = Modifier
.semantics {
role = Role.Button
contentDescription = "${product.name}, ${product.price}"
}
.clickable(onClick = onClick)
.fillMaxWidth()
.clip(RoundedCornerShape(AppTheme.spacing.sm))
.background(AppTheme.colors.surface)
.padding(AppTheme.spacing.md),
) {
// child elements don't need individual descriptions —
// the parent semantics covers the whole card
Text(product.name)
Text(product.price)
}
}
```
---
## mergeDescendants — group related elements
```kotlin
// ListItem with icon + two text lines: group as one semantic node
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
contentDescription = "${item.title}: ${item.subtitle}"
}
) {
Icon(item.icon, contentDescription = null) // null — parent merges
Column {
Text(item.title)
Text(item.subtitle)
}
}
```
---
## Traversal order (traversalIndex)
By default, TalkBack and VoiceOver traverse composables in layout order. Override when
visual layout differs from logical reading order:
```kotlin
// Heading should be announced before the body even if visually below
Text(
"Section title",
modifier = Modifier.semantics { traversalIndex = -1f },
)
Text(
"Section body",
modifier = Modifier.semantics { traversalIndex = 0f },
)
```
---
## Minimum touch target — 48×48dp
```kotlin
// Icon that is visually 24dp must have a 48dp touch target
IconButton(
onClick = onClose,
modifier = Modifier.size(48.dp), // explicit; never smaller
) {
Icon(Icons.Default.Close, contentDescription = "Close", modifier = Modifier.size(24.dp))
}
// Or use minimumInteractiveComponentSize:
Box(
modifier = Modifier
.minimumInteractiveComponentSize() // enforces 48dp minimum automatically
.clickable(onClick = onClose),
) {
Icon(Icons.Default.Close, contentDescription = "Close", modifier = Modifier.size(24.dp))
}
```
---
## Live regions — dynamic content announcements
```kotlin
// Status message that should be read aloud when it changes
val statusMessage by viewModel.statusMessage.collectAsStateWithLifecycle()
Text(
text = statusMessage,
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite // announce change without interrupting
},
)
```
---
## State descriptions
```kotlin
// Toggle button — readable state label
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = { expanded = !expanded },
modifier = Modifier.semantics {
contentDescription = if (expanded) "Collapse section" else "Expand section"
stateDescription = if (expanded) "Expanded" else "Collapsed"
}
) {
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
)
}
```
---
## Roborazzi accessibility snapshot
```kotlin
// :ui:test — HomeScreenA11yTest.kt
@RunWith(RobolectricTestRunner::class)
class HomeScreenA11yTest {
@get:Rule val composeRule = createComposeRule()
@Test fun `home screen passes accessibility audit`() {
composeRule.setContent {
AppTheme {
HomeContent(
state = HomeContract.State(items = sampleItems),
onIntent = {},
)
}
}
composeRule
.onRoot()
.captureRoboImage(
filePath = "src/test/snapshots/a11y/home_screen_a11y.png",
roborazziOptions = RoborazziOptions(
captureType = RoborazziOptions.CaptureType.Dump(
explanation = AccessibilityExplanation.Default,
),
),
)
}
}
```
The accessibility dump snapshot shows the semantic tree overlaid on the screen —
review it to spot missing labels, incorrect roles, and traversal order issues.
---
## Compose a11y audit checklist
Run this checklist per screen before shipping:
| Check | Rule |
|---|---|
| Every `IconButton` has a non-null `contentDescription` | WCAG 1.1.1 |
| Every `AsyncImage` / `Image` with meaningful content has a description | WCAG 1.1.1 |
| Every custom clickable has `role = Role.Button` (or Link, Checkbox, etc.) | WCAG 4.1.2 |
| Touch targets are ≥ 48×48dp | WCAG 2.5.5 |
| Color is not the only way to convey information (e.g., errors also have a label) | WCAG 1.4.1 |
| Focus order matches logical reading order | WCAG 2.4.3 |
| Dynamic content updates have `liveRegion` | WCAG 4.1.3 |
| Screen title is announced (TopAppBar title or `heading = true` on a Text) | WCAG 2.4.2 |
| Animations respect `LocalAccessibilityManager.isReduceMotionEnabled` | WCAG 2.3.3 |
**This checklist is a Compose binding of WCAG, not a Compose-specific standard** — every
row above cites the real WCAG success criterion because the requirement itself is
framework-agnostic; only the API used to satisfy it changes per platform:
| WCAG criterion | Compose | Web (`ts-accessibility`) |
|---|---|---|
| 1.1.1 (non-text content) | `contentDescription` | `alt`, `aria-label` |
| 4.1.2 (name, role, value) | `Modifier.semantics { role = Role.Button }` | native `<button>`, or `role="button"` |
| 2.5.5 (target size) | 48×48dp minimum | 44×44 CSS px minimum |
| 4.1.3 (status messages) | `liveRegion` | `aria-live` |
| 2.3.3 (animation from interactions) | `LocalAccessibilityManager.isReduceMotionEnabled` | `prefers-reduced-motion` media query |
If a project ships both a KMP/Compose app and a web frontend, the accessibility
*requirement* is identical across both — verify against this table and `ts-accessibility`
together rather than re-deriving the WCAG mapping twice.
---
## Screen heading announcement
```kotlin
AppTopAppBar(
title = {
Text(
"Settings",
modifier = Modifier.semantics { heading() },
)
},
)
```
---
## Common Anti-Patterns
- **Icon without `contentDescription`** — `Icon(Icons.Default.Share, contentDescription = null)`
is a silent element; TalkBack reads nothing; every interactive icon must have a label
- **`contentDescription` that mirrors the visible text** — `Text("Save")` next to a button
labeled `"Save"` with `contentDescription = "Save"` is redundant; merge with
`semantics(mergeDescendants = true)` so the button label suffices
- **Custom composable without `role`** — a `Box` with `.clickable {}` is announced as
"double-tap to activate" with no role; add `semantics { role = Role.Button }`
- **Relying only on color for errors** — red text without an accompanying error icon or
label fails WCAG 1.4.1; always pair color with a label or icon
- **Unconstrained touch targets** — icon buttons sized 24dp are too small for users with
motor impairments; always use `Modifier.size(48.dp)` or `minimumInteractiveComponentSize()`
---
## Related Skills
- `kmp-roborazzi` — a11y snapshot tests extend the Roborazzi setup;
`captureRoboImage` with `CaptureType.Dump` generates the semantic tree overlay
- `kmp-compose-animation` — `respectsReducedMotion()` check is an a11y
requirement; link both skills when adding animations to a screen
- `kmp-compose-design-system` — color contrast ratios must meet WCAG AA (4.5:1
for normal text, 3:1 for large text); verify in the design system token definitions
---
## Output Style
When implementing accessibility, respond in this order:
1. **Audit findings** — list what is missing on the target screen using the checklist
2. **contentDescription fixes** — icons, images, interactive elements
3. **Role declarations** — custom clickable composables
4. **Touch target fixes** — `minimumInteractiveComponentSize()` or explicit `size(48.dp)`
5. **mergeDescendants** — group related row/card children
6. **Roborazzi a11y snapshot** — one test per screen
7. **Checklist** — confirm each row is satisfied
---
## Changelog
| Date | Change |
|---|---|
| 2026-08-24 | Added a "WCAG criterion → Compose / Web" table right after the a11y checklist — user asked to extract the accessibility guidance into generic, framework-agnostic form. The checklist already cited real WCAG criteria per row; the gap was stating explicitly that the requirement is universal and cross-referencing `ts-accessibility` for the web binding, not re-deriving the mapping. |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!