Skip to content
Back to skills

Kmp Compose Accessibility

ASecurity

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.

  • 2 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 2, 2026
ai-agentskotlinnodeapifrontend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 27, 2026

npx -y skills add ronjunevaldoz/kmp-agent-skills --skill kmp-compose-accessibility --agent claude-code

Installs 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 with every re-scan.

Security grade badge for Kmp Compose Accessibility
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/ronjunevaldoz-kmp-compose-accessibility/badge)](https://www.skillsdirectory.com/skills/ronjunevaldoz-kmp-compose-accessibility)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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-09-27'
  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 `rememberReduceMotion()` (expect/actual, `kmp-compose-animation`) | 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) | `rememberReduceMotion()` expect/actual over each platform's setting | `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-09-27 | Reduced-motion rows pointed at `LocalAccessibilityManager.isReduceMotionEnabled`, which doesn't exist in Compose's common `AccessibilityManager` (only `calculateRecommendedTimeoutMillis`). Now point at `kmp-compose-animation`'s `rememberReduceMotion()` expect/actual. |
| 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. |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.