Mobile No. 340

Mobile Accessibility skill for Claude Code

Apps that work with VoiceOver, TalkBack and huge text. Claude audits and fixes native and cross-platform apps so critical flows can be completed eyes-free with VoiceOver and TalkBack. It writes concise labels, exposes roles, values and states, groups cards with custom actions, manages focus after navigation, and makes layouts reflow at the largest text sizes, using a concept-to-API map for SwiftUI, UIKit, Compose, Views, React Native and Flutter.

  • namemobile-accessibility
  • size196 lines · ≈2,350 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Makes native and cross-platform mobile apps usable with VoiceOver and TalkBack — accessible names, roles/traits, states, grouping, focus order and announcements — plus Dynamic Type and font scaling, 44pt/48dp touch targets, contrast, reduce-motion, and automated audits (Accessibility Inspector, XCUITest audits, Accessibility Scanner, Espresso and Compose checks, Flutter guidelines). Covers SwiftUI, UIKit, Jetpack Compose, Android Views, React Native, and Flutter. Use when auditing or fixing app accessibility, labeling icon buttons, supporting large text, or preparing for EAA/WCAG compliance on mobile.

Try asking

  • Audit our checkout flow with VoiceOver and TalkBack and list what breaks
  • Our icon buttons read as just 'button' — fix labels across the app
  • Make the order summary screen work at the largest accessibility text size

Use it when

  • Auditing an iOS, Android, React Native or Flutter app for accessibility
  • Screen readers announce unlabeled or duplicated elements
  • Layouts clip or overlap at large text sizes
  • Preparing for European Accessibility Act or WCAG obligations on mobile

Skip it when

How to check the result

Each critical flow is completed with VoiceOver and TalkBack without looking, largest text sizes show no clipping, and UI-test accessibility audits pass on changed screens.

The common mistake it prevents

Labeling icon buttons with what the icon looks like or adding the word button to the label, so users hear 'trash button, button' instead of the action.

~/.claude/skills/mobile-accessibility/SKILL.md

--- name: mobile-accessibility description: Makes native and cross-platform mobile apps usable with VoiceOver and TalkBack — accessible names, roles/traits, states, grouping, focus… license: MIT ---

An accessible screen can be completed end to end with VoiceOver and TalkBack, with each element announcing a meaningful name, role, and state in a logical order; it reflows without truncation at the largest text sizes; every target is at least 44×44 pt (iOS) or 48×48 dp (Android); and it respects reduce motion and contrast settings. Mobile apps are judged against WCAG 2.2 AA (via EN 301 549 for the European Accessibility Act).

Workflow

- [ ] 1. List critical flows (onboarding, sign-in, core task, purchase, settings)
- [ ] 2. Automated audit per screen (platform tools below)
- [ ] 3. Screen reader pass: VoiceOver on iPhone, TalkBack on Android
- [ ] 4. Labels, roles/traits, values, states; group related content
- [ ] 5. Focus order, focus after navigation/modals, announcements for async changes
- [ ] 6. Largest text sizes: no clipping, layouts reflow
- [ ] 7. Touch targets, contrast, color independence
- [ ] 8. Motion, transparency, bold text, dark mode, orientation
- [ ] 9. Add automated checks to UI tests; write findings

Screen reader basics for testing

VoiceOver (iOS)TalkBack (Android)
ToggleSettings → Accessibility → Accessibility Shortcut (triple-click side button)Hold both volume keys 3 s (after enabling the shortcut)
Next / previous elementSwipe right / leftSwipe right / left
ActivateDouble-tapDouble-tap
Navigate by headings etc.Rotor (two-finger rotate)Reading controls (swipe up/down, or three-finger gestures on recent versions)
Custom actionsSwipe up/down on elementTalkBack menu / actions

Listen for: unlabeled buttons (“button” alone), image file names read aloud, decorative images announced, the same thing read twice, a missing state (“selected”, “off”), focus jumping to the top after an update, and elements unreachable by swiping.

Names, roles, states

Rules (APIs per framework are in the reference below):

  • Every interactive element has a concise label describing its purpose: “Delete message”, not “trash icon” or “button”. Do not include the role in the label (“Delete button” reads “Delete button, button”).
  • Hints are optional and describe the result (“Double-tap to open the order”); never put essential information only in hints — users disable them.
  • Expose roles/traits: button, link, header, image, selected, adjustable (sliders, steppers), toggle/switch state.
  • Expose dynamic values: “Volume, 60 percent”, “Quantity, 2”.
  • Hide decorative images and duplicated text from assistive tech.
  • Group related content into one element when it reads better (a product card: “Blue mug, 18 dollars, 4.5 stars”), and expose secondary actions as custom actions instead of many tiny focus stops.
  • Mark section titles as headers so rotor/heading navigation works.
// SwiftUI
Button(action: delete) { Image(systemName: "trash") }
  .accessibilityLabel("Delete message")

HStack { AsyncImage(url: product.imageURL); VStack { Text(product.name); Text(product.price) } }
  .accessibilityElement(children: .combine)
  .accessibilityAction(named: "Add to cart") { addToCart(product) }

Text("Recent orders").accessibilityAddTraits(.isHeader)
// Jetpack Compose
IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Delete message") }

Row(Modifier.semantics(mergeDescendants = true) {
    customActions = listOf(CustomAccessibilityAction("Add to cart") { onAdd(); true })
}) { /* image, name, price */ }

Text("Recent orders", modifier = Modifier.semantics { heading() })
Image(painter, contentDescription = null)   // decorative
// React Native
<Pressable onPress={onDelete} accessibilityRole="button" accessibilityLabel="Delete message" hitSlop={8}>
  <TrashIcon aria-hidden />
</Pressable>
// Flutter
Semantics(label: 'Delete message', button: true, child: IconButton(icon: const Icon(Icons.delete), onPressed: onDelete))
// IconButton also accepts tooltip:, which provides the semantic label

Focus and announcements

  • Focus order follows reading order. If visual order and code order differ, fix layout order first; use explicit ordering (accessibilitySortPriority, traversalIndex) only as a last resort.
  • After navigation, focus lands on the new screen’s title or first meaningful element; after closing a modal, focus returns to the control that opened it.
  • Modals and sheets trap focus inside while open (accessibilityViewIsModal in UIKit, native sheets/dialogs do this; custom overlays must).
  • Announce async results that do not move focus (“Saved”, “3 results”): iOS AccessibilityNotification.Announcement("Saved").post() (or UIAccessibility.post); Android prefer live regions (liveRegion = LiveRegionMode.Polite in Compose, android:accessibilityLiveRegion) over announceForAccessibility, which recent Android versions discourage.
  • Errors in forms: announce and move focus to the first invalid field; the error text is associated with the field.

Text scaling

  • iOS: use text styles (.font(.body), UIFont.preferredFont(forTextStyle:) with adjustsFontForContentSizeCategory = true); scale custom sizes with @ScaledMetric or UIFontMetrics. Test up to the largest accessibility size (AX5).
  • Android: use sp for text (never dp); Android 14+ scales non-linearly up to 200%. Test at 200% font and largest display size.
  • Layouts must reflow: switch horizontal rows to vertical at large sizes (ViewThatFits, dynamicTypeSize.isAccessibilitySize, FlowRow), allow multi-line text (no fixed heights, no lineLimit(1) on important text), keep everything scrollable.
  • Do not cap scaling except for content that genuinely cannot grow (tab bar labels may use the large-content viewer on iOS instead).

Targets, contrast, color

  • Minimum touch target 44×44 pt on iOS and 48×48 dp on Android, even if the visible icon is smaller (expand the hit area: contentShape, minimumInteractiveComponentSize(), hitSlop, padding).
  • Text contrast at least 4.5:1 (3:1 for large text); icons and control boundaries 3:1. Check both light and dark themes and “Increase Contrast” variants.
  • Never use color alone for status (errors, required, selected); add text or icons.

System settings to respect

SettingiOSAndroidBehavior
Reduce motion@Environment(\.accessibilityReduceMotion) / UIAccessibility.isReduceMotionEnabledSettings.Global.TRANSITION_ANIMATION_SCALE / “Remove animations”Replace parallax, zoom, autoplay with fades or nothing
Reduce transparencyaccessibilityReduceTransparency—Solid backgrounds behind text
Bold textlegibilityWeightBold text setting (font weight adjustment)Use system fonts/weights that respond
Dark mode / contrastcolorScheme, colorSchemeContrastDark theme, high-contrast textSemantic colors
Orientation——Support both unless essential (WCAG 1.3.4)

Automated checks

  • iOS: Xcode Accessibility Inspector → Audit on simulator; in UI tests try app.performAccessibilityAudit() (Xcode 15+) fails on missing labels, small hit areas, contrast, and Dynamic Type clipping.
  • Android: Accessibility Scanner app on device; Espresso AccessibilityChecks.enable(); recent Compose UI test versions can enable accessibility checks on the test rule; Play Console pre-launch report includes accessibility suggestions.
  • React Native: run the native tools on the built app; lint with eslint-plugin-react-native-a11y.
  • Flutter widget tests:
testWidgets('meets a11y guidelines', (tester) async {
  final handle = tester.ensureSemantics();
  await tester.pumpWidget(const MyApp());
  await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
  await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
  await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
  await expectLater(tester, meetsGuideline(textContrastGuideline));
  handle.dispose();
});

Automation catches missing labels and sizes; it cannot judge label quality, order, or whether a flow is completable. The manual screen reader pass is mandatory.

For the full concept-to-API table across SwiftUI, UIKit, Compose, Android Views, React Native, and Flutter, read references/platform-api-map.md when implementing fixes in a specific framework.

Findings format

### M-03 — Serious — Cart: quantity stepper not adjustable (WCAG 4.1.2)
**Platform:** iOS 18 VoiceOver, Android 15 TalkBack
**Observed:** Announces "plus button", "minus button", value not read; no state after change.
**Expected:** One adjustable element "Quantity, 2"; swipe up/down changes value and announces it.
**Fix:** SwiftUI `.accessibilityElement(children: .ignore).accessibilityLabel("Quantity").accessibilityValue("\(qty)").accessibilityAdjustableAction {...}`;
Compose `Modifier.semantics { stateDescription = "$qty"; setProgress {...} }` or custom actions.
**Verify:** VoiceOver and TalkBack adjust and announce; UI accessibility audit passes.

Verification

  1. Each critical flow completed with VoiceOver and with TalkBack, eyes off the screen.
  2. Largest text size on both platforms: no clipped or overlapping text, all content reachable.
  3. Automated audits pass in UI tests for the screens changed.
  4. Reduce motion and dark/high-contrast modes checked on changed screens.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/platform-api-map.md 64 lines · 8.4 KB
# Accessibility API map across mobile frameworks

Read this when implementing a fix in a specific framework. Each row is one concept; use the
column for the project's UI toolkit. Verify exact signatures against the installed SDK when
in doubt; names below are current for iOS 17–26, Compose 1.7+, React Native 0.7x+, and
Flutter 3.x.

## Names, hints, values

| Concept | SwiftUI | UIKit | Jetpack Compose | Android Views | React Native | Flutter |
|---|---|---|---|---|---|---|
| Label | `.accessibilityLabel("…")` | `accessibilityLabel` | `contentDescription` (Icon/Image) or `Modifier.semantics { contentDescription = "…" }` | `android:contentDescription` | `accessibilityLabel` / `aria-label` | `Semantics(label:)`, `tooltip:` on IconButton |
| Hint | `.accessibilityHint("…")` | `accessibilityHint` | `onClick(label = "open order")` action label | `ViewCompat.replaceAccessibilityAction` with label / `tooltipText` | `accessibilityHint` | `Semantics(hint:)`, `onTapHint` |
| Value | `.accessibilityValue("60 percent")` | `accessibilityValue` | `semantics { stateDescription = "…" }` | `ViewCompat.setStateDescription` | `accessibilityValue={{ now, min, max, text }}` | `Semantics(value:)` |
| Hide decorative | `.accessibilityHidden(true)`; `Image(decorative:)` | `isAccessibilityElement = false` | `contentDescription = null`; `Modifier.clearAndSetSemantics {}` | `importantForAccessibility="no"` | `accessible={false}` / `aria-hidden` / `importantForAccessibility="no-hide-descendants"` | `ExcludeSemantics` |

## Roles, traits, states

| Concept | SwiftUI | UIKit | Jetpack Compose | Android Views | React Native | Flutter |
|---|---|---|---|---|---|---|
| Button | `Button` or `.accessibilityAddTraits(.isButton)` | `.button` trait | `Modifier.clickable(role = Role.Button)` / `semantics { role = Role.Button }` | `Button` or `AccessibilityDelegateCompat` class name | `accessibilityRole="button"` / `role="button"` | `Semantics(button: true)` |
| Header | `.accessibilityAddTraits(.isHeader)` | `.header` | `semantics { heading() }` | `ViewCompat.setAccessibilityHeading(view, true)` | `accessibilityRole="header"` | `Semantics(header: true)` |
| Link | `Link` / `.isLink` | `.link` | `LinkAnnotation` in text / `Role` custom | `URLSpan` / `ClickableSpan` | `accessibilityRole="link"` | `Semantics(link: true)` |
| Selected | `.accessibilityAddTraits(.isSelected)` | `.selected` | `semantics { selected = true }` / `Modifier.selectable` | `isSelected` | `accessibilityState={{ selected: true }}` | `Semantics(selected: true)` |
| Toggle state | `Toggle` (automatic) | `UISwitch` / `.toggleButton` trait (iOS 17) | `Modifier.toggleable(value, role = Role.Switch)` | `SwitchCompat`/`CheckBox` | `accessibilityRole="switch"` + `accessibilityState={{ checked }}` | `Semantics(toggled:)` / `checked:` |
| Disabled | `.disabled(true)` | `.notEnabled` | `semantics { disabled() }` / `enabled = false` | `isEnabled = false` | `accessibilityState={{ disabled: true }}` | `Semantics(enabled: false)` |
| Adjustable (slider/stepper) | `.accessibilityAdjustableAction { dir in … }` | `.adjustable` + `accessibilityIncrement/Decrement` | `semantics { setProgress { … } ; progressBarRangeInfo = … }` | `SeekBar` or delegate with range info | `accessibilityRole="adjustable"` + `accessibilityActions` increment/decrement | `Semantics(onIncrease:, onDecrease:, value:, increasedValue:, decreasedValue:)` |

## Grouping and custom actions

| Concept | SwiftUI | UIKit | Jetpack Compose | Android Views | React Native | Flutter |
|---|---|---|---|---|---|---|
| Merge children into one element | `.accessibilityElement(children: .combine)` | Container `isAccessibilityElement = true` + composed label | `Modifier.semantics(mergeDescendants = true) {}` | `android:focusable="true"` on container + `screenReaderFocusable` | `accessible` on parent View | `MergeSemantics` |
| Replace children with custom description | `.accessibilityElement(children: .ignore)` + label | as above | `Modifier.clearAndSetSemantics { contentDescription = … }` | container contentDescription + hide children | `accessible` + `accessibilityLabel` | `Semantics(excludeSemantics: true, label:)` |
| Custom actions | `.accessibilityAction(named: "Archive") { … }` | `accessibilityCustomActions` | `semantics { customActions = listOf(CustomAccessibilityAction("Archive") { …; true }) }` | `ViewCompat.addAccessibilityAction` | `accessibilityActions` + `onAccessibilityAction` | `Semantics(customSemanticsActions: {…})` |

## Focus, order, announcements

| Concept | SwiftUI | UIKit | Jetpack Compose | Android Views | React Native | Flutter |
|---|---|---|---|---|---|---|
| Move focus | `@AccessibilityFocusState` + `.accessibilityFocused($flag)` | `UIAccessibility.post(notification: .layoutChanged, argument: view)` | `FocusRequester` (input focus); screen reader focus follows pane changes | `view.sendAccessibilityEvent(TYPE_VIEW_FOCUSED)` / `performAccessibilityAction(ACTION_ACCESSIBILITY_FOCUS)` | `AccessibilityInfo.setAccessibilityFocus(findNodeHandle(ref))` | `SemanticsService.sendAnnouncement` / `FocusNode` for input focus |
| Screen changed | Automatic on navigation | `.screenChanged` notification | `semantics { paneTitle = "…" }` | `accessibilityPaneTitle` | navigation libraries handle; else set focus | Route changes announce `Semantics(namesRoute: true, scopesRoute: true)` |
| Order override | `.accessibilitySortPriority(n)` | `accessibilityElements` array | `semantics { traversalIndex = n }`, `isTraversalGroup` | `accessibilityTraversalBefore/After` | (layout order) | `Semantics(sortKey: OrdinalSortKey(n))` |
| Announce message | `AccessibilityNotification.Announcement("Saved").post()` | `UIAccessibility.post(notification: .announcement, argument: "Saved")` | `semantics { liveRegion = LiveRegionMode.Polite }` on the status text | `android:accessibilityLiveRegion="polite"` | `AccessibilityInfo.announceForAccessibility("Saved")`; `accessibilityLiveRegion` (Android) | `SemanticsService.sendAnnouncement(View.of(context), "Saved", TextDirection.ltr)` on recent Flutter; `liveRegion: true` |
| Modal focus trap | Native sheets; `.accessibilityAddTraits(.isModal)` | `accessibilityViewIsModal = true` | `Dialog`/`ModalBottomSheet` handle it | `Dialog` handles it | `Modal` / `accessibilityViewIsModal` (iOS) | `ModalRoute`/dialogs handle it; `BlockSemantics` for custom overlays |

## Text scaling and settings

| Concept | SwiftUI | UIKit | Jetpack Compose | Android Views | React Native | Flutter |
|---|---|---|---|---|---|---|
| Scalable text | Text styles `.font(.body)` | `preferredFont(forTextStyle:)` + `adjustsFontForContentSizeCategory` | `MaterialTheme.typography` (sp) | `textSize` in `sp` | `allowFontScaling` (default true), `maxFontSizeMultiplier` sparingly | `Theme.of(context).textTheme`; `MediaQuery.textScalerOf` |
| Scale custom metrics | `@ScaledMetric var size = 24` | `UIFontMetrics.default.scaledValue(for:)` | `with(LocalDensity.current) { 24.sp.toDp() }` | `TypedValue.applyDimension(COMPLEX_UNIT_SP, …)` | `PixelRatio.getFontScale()` | `MediaQuery.textScalerOf(context).scale(24)` |
| Large-size layout switch | `@Environment(\.dynamicTypeSize)` `.isAccessibilitySize`; `ViewThatFits` | `traitCollection.preferredContentSizeCategory.isAccessibilityCategory` | `LocalDensity.current.fontScale > 1.5`; `FlowRow` | `Configuration.fontScale` | `useWindowDimensions().fontScale` | `MediaQuery.textScalerOf(context)` |
| Reduce motion | `@Environment(\.accessibilityReduceMotion)` | `UIAccessibility.isReduceMotionEnabled` | `Settings.Global.ANIMATOR_DURATION_SCALE == 0f` | same | `AccessibilityInfo.isReduceMotionEnabled()` | `MediaQuery.disableAnimationsOf(context)` |
| Touch target | `.contentShape(Rectangle())` + `.frame(minWidth: 44, minHeight: 44)` | enlarge frame / `point(inside:with:)` | `Modifier.minimumInteractiveComponentSize()` (Material default) | `minHeight/minWidth 48dp`, `TouchDelegate` | `hitSlop`, `minHeight: 44` | `MaterialTapTargetSize.padded`, `SizedBox(48, 48)` |

## Testing hooks

| Platform | Tool |
|---|---|
| iOS | Accessibility Inspector audit; `XCUIApplication().performAccessibilityAudit()`; VoiceOver caption panel in Simulator |
| Android | Accessibility Scanner; `AccessibilityChecks.enable()` (Espresso); Compose `onNodeWithContentDescription`, semantics matchers; Layout Inspector semantics view |
| React Native | Native tools on the built app; Testing Library `getByRole`, `getByLabelText` |
| Flutter | `meetsGuideline(...)` in widget tests; `debugDumpSemanticsTree()`; `MaterialApp(showSemanticsDebugger: true)` |

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc