Skip to main content

Accessibility Deep Dive

Accessibility isn't a checklist — it's how blind, low-vision, motor- impaired, and cognitively-disabled users interact with your app. Done right, it also makes the app better for everyone (keyboard nav works, screens respond to system font scale, TV-style D-pad navigation).

Android's accessibility stack is built on semantics nodes — an abstract tree that screen readers, switch access, and voice control all consume. Compose semantics integrate directly.

The four WCAG 2.2 AA non-negotiables

  1. 01

    Every interactive element is labelled

    Buttons, IconButtons, clickable Boxes need a content description or visible text. TextFields need associated labels.

  2. 02

    Touch targets are ≥ 48x48 dp

    The WCAG 2.1 AA minimum. Use Modifier.minimumInteractiveComponentSize() — not manual padding.

  3. 03

    Color contrast ≥ 4.5:1 (body text), 3:1 (large text + graphics)

    WCAG 2.1 AA. Material 3 M3 "onSurface" tokens comply; custom palettes must be audited.

  4. 04

    End-to-end flows work with TalkBack

    A blind user must complete sign-in, primary journey, and sign-out using TalkBack alone.


Compose semantics basics

@Composable
fun AccessibleIconButton(
onClick: () -> Unit,
icon: ImageVector,
label: String, // required content description
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier.semantics { contentDescription = label }
) {
Icon(icon, contentDescription = null) // parent owns the description
}
}

Merging descendants

For a complex composable that should be announced as one unit:

@Composable
fun ProductCard(product: Product, onClick: () -> Unit) {
Column(
modifier = Modifier
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) {
contentDescription = "${product.name}, ${product.priceLabel}, in stock"
role = Role.Button
}
) {
AsyncImage(product.imageUrl, contentDescription = null)
Text(product.name)
Text(product.priceLabel)
}
}

Without mergeDescendants = true, TalkBack reads each child separately — "image, Wireless Earbuds, $99, in stock" — clunky. With merge — one fluent announcement.

Clearing and setting semantics

Modifier.clearAndSetSemantics {
contentDescription = "Close dialog"
role = Role.Button
}

Removes any descendant semantics and sets only what you specify. Use sparingly — typically for custom composites where auto-merge isn't precise enough.

Live regions

For dynamic content (cart badge updates, notification count):

Text(
text = "$count items",
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
}
)
  • Polite — announced when TalkBack is idle
  • Assertive — interrupts current announcement (use sparingly)

Custom actions

Row(
modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Archive") { onArchive(); true },
CustomAccessibilityAction("Delete") { onDelete(); true },
CustomAccessibilityAction("Mark as read") { onMarkRead(); true }
)
}
) { /* swipeable row */ }

Swipe gestures that work for sighted users (archive/delete/read) become TalkBack menu items — "Actions available: Archive, Delete, Mark as read".

Role

Modifier.semantics {
role = Role.Button // or Checkbox, RadioButton, Switch, Tab, DropdownList, Image
}

Role tells TalkBack what the element is — affects hint ("double-tap to activate"), announcements on focus change.

Headings

Text(
text = "Profile",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() }
)

TalkBack users can navigate by headings with a gesture. Mark section titles, subheadings — makes long pages scannable.

State descriptions

Switch(
checked = darkMode,
onCheckedChange = { darkMode = it },
modifier = Modifier.semantics {
stateDescription = if (darkMode) "Dark theme enabled" else "Light theme enabled"
}
)

Overrides the default on/off announcement with descriptive text.


Touch targets

// Automatic — applies 48dp minimum
IconButton(onClick = { }) { Icon(Icons.Default.Close, null) }

// Manual for custom composables
Box(
modifier = Modifier
.minimumInteractiveComponentSize()
.clickable(onClick = onClick)
.size(24.dp) // visual size
) { /* ... */ }

minimumInteractiveComponentSize adds invisible padding to reach 48dp without changing visual appearance. Every clickable composable should use it (Material components already do).


Color contrast

Material 3 is compliant by default

MaterialTheme.colorScheme.onSurface on surface hits 4.5:1. Tokens automatically pass WCAG AA.

Custom colors — audit

fun Color.contrastRatio(other: Color): Float {
val l1 = luminance() + 0.05f
val l2 = other.luminance() + 0.05f
return max(l1, l2) / min(l1, l2)
}

// Test in your theme setup
check(customText.contrastRatio(customBackground) >= 4.5f) {
"Contrast too low: ${customText.contrastRatio(customBackground)}"
}

Tools


Font scaling

Users can scale font up to 2.0× (sometimes higher). Layouts must accommodate:

// ❌ Fixed height clips at large fonts
Text("Long message", modifier = Modifier.height(24.dp))

// ✅ heightIn(min) grows with font
Text(
"Long message",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.heightIn(min = 24.dp)
)

Always test at 2.0×

@Preview(fontScale = 2f, showBackground = true)
@Composable
fun LargeFontPreview() {
AppTheme { MyScreen() }
}

TalkBack testing

Enable on device

Settings → Accessibility → TalkBack → On. Or enable with a single button (Settings → Accessibility → Accessibility shortcuts → TalkBack).

Gestures

  • Swipe right — next focusable element
  • Swipe left — previous element
  • Double-tap — activate
  • Three-finger swipe up/down — adjust reading granularity (words, chars, headings)
  • Swipe down-then-up — open local context menu (custom actions)

Reading order

TalkBack reads in semantic tree order, which is usually the same as visual top-to-bottom. If it's wrong, fix via:

Modifier.semantics {
// Override traversal order within a Row / Box
traversalIndex = 1.0f
}

Heading navigation

Users can enable "Headings" as the navigation granularity and skim through a page by heading only. Your heading() semantics annotations enable this.


Switch Access

For motor-impaired users who navigate via 1-2 physical switches:

  • Every action must be reachable by sequential focus
  • Visible focus indicator must be obvious
  • Auto-scan speed should be tunable (system setting)
// Compose — focus indication
Modifier.focusable(
interactionSource = interactionSource,
enabled = true
)
.onFocusChanged { focused ->
if (focused) scrollIntoView()
}

Focus order

For custom layouts, control the traversal order:

Modifier.focusProperties {
next = FocusRequester(submitBtnRef)
right = FocusRequester(cancelBtnRef)
up = FocusRequester(prevFieldRef)
}

Voice Access

Voice Access adds numbered labels to on-screen interactive elements — users can say "tap 3" to activate. Requires:

  • Each interactive element has a labelled name (from contentDescription or text)
  • Unique labels (two buttons both labelled "Edit" confuse voice matching)

dp vs sp — the density correctness rule

Text(fontSize = 16.sp) // ✅ scales with user font size
Box(modifier = Modifier.size(24.dp)) // ✅ scales with screen density

Text(fontSize = 16.dp) // ❌ doesn't scale with font setting

Always sp for text, dp for everything else.


WindowInsets — safe areas

Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) // avoid status bar, nav bar, IME
.padding(16.dp)
) { /* ... */ }

Content that's behind the status bar or keyboard is inaccessible. Modern apps use WindowInsets.safeDrawing or specific insets (statusBars, imeHint, navigationBars).


Automated audits

Google Accessibility Test Framework

// androidTest
@HiltAndroidTest
class ProfileA11yTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val compose = createAndroidComposeRule<HiltTestActivity>()

@Test
fun profile_screen_passes_a11y_audit() {
compose.setContent { AppTheme { ProfileScreen() } }
compose.onRoot().performAccessibilityAudit()
}
}

Checks:

  • Touch target size
  • Contrast ratio
  • Content description presence
  • Redundant descriptions
  • Clickable span text

Run on every screen. Audit failures block PR merge.

Compose-native assertions

@Test fun all_buttons_have_labels() {
compose.setContent { ProfileScreen() }

compose.onAllNodesWithRole(Role.Button)
.assertAll(hasContentDescription() or hasText())
}

@Test fun touch_targets_meet_minimum() {
compose.setContent { ProfileScreen() }

compose.onAllNodes(hasClickAction())
.fetchSemanticsNodes()
.forEach { node ->
val size = node.size
assertTrue(size.width >= 48.dp.toPx(density) && size.height >= 48.dp.toPx(density)) {
"Element ${node.config} has size ${size.width}x${size.height}, expected min 48dp"
}
}
}

Accessibility Scanner app

Install from Play Store. Runs on your device, captures screenshots, and reports violations:

  • Small touch targets
  • Low contrast
  • Missing labels
  • Duplicate descriptions

Run it on every screen of your app before release.


Designing for accessibility from day one

In mockups

  • Mark each element with its intended screen-reader announcement
  • Specify semantic role (button, heading, image, etc.)
  • Define touch targets explicitly (not just visual size)
  • Include dark mode + large-font-scale variants

In code review

  • No unlabelled IconButton — fail the PR
  • No contentDescription = "" without clearAndSet — questionable
  • Custom composite → check mergeDescendants — does it read as one unit?
  • Animation → respects reduce-motion — see below

Per-feature checklist

  1. 01

    Navigate the screen with TalkBack

    From the entry point to success, using only swipes and double-taps.

  2. 02

    Test at font scale 2.0×

    Nothing clips; scrollable areas scroll; form fields expand.

  3. 03

    Test in dark mode

    All text still meets 4.5:1 contrast; no pure white / black text.

  4. 04

    Test in RTL (ar-XB)

    Layouts flip correctly; directional icons mirror; no hardcoded left/right.

  5. 05

    Run accessibility audit

    performAccessibilityAudit() or Accessibility Scanner — no violations.

  6. 06

    Test with external keyboard

    Tab through every focusable element; focus indicator is visible.


Animations and reduce-motion

Some users have vestibular disorders; Android has a system setting to reduce motion:

@Composable
fun motionAwareAnimationSpec(baseDurationMs: Int): AnimationSpec<Float> {
val context = LocalContext.current
val animatorScale = Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
)

return if (animatorScale < 0.01f) {
snap() // no animation
} else {
tween((baseDurationMs * animatorScale).toInt())
}
}

Use in any animate*AsState that isn't purely functional (not progress indicators — those are useful information, not decoration).


Images

// Decorative image — no announcement
Image(painter, contentDescription = null)

// Informative image — describe the content
Image(painter, contentDescription = "Line chart showing orders up 12% this month")

// Interactive image (tappable) — describe the action
Image(
painter,
contentDescription = "Open order details",
modifier = Modifier.clickable { /* ... */ }
)

Never put visual description when the image is decorative — it just adds noise ("image, image, image" in TalkBack is user-hostile).


Forms and error messages

OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text("Email") },
isError = emailError != null,
supportingText = emailError?.let {
{ Text(it, modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }) }
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
)

Error messages should be live regions so TalkBack announces them when they appear — users know immediately why the field is invalid.


Accessibility service testing

Espresso accessibility checks (legacy Views)

@Before
fun enableA11yChecks() {
AccessibilityChecks.enable()
.setRunChecksFromRootView(true)
.setThrowExceptionFor(AccessibilityCheckResultType.ERROR)
}

Every Espresso onView().perform() now runs accessibility checks implicitly. Fails the test on violations.


Common anti-patterns

Anti-patterns

A11y mistakes

  • IconButton without contentDescription
  • Custom composite without mergeDescendants
  • Hardcoded dp for text (doesn't respect font scale)
  • Fixed heights that clip at 2x font
  • Left / right padding instead of start / end
  • Decorative images with contentDescription filled in
  • No TalkBack testing before release
Best practices

Accessible by default

  • Every IconButton has a meaningful label
  • Cards use mergeDescendants for one announcement
  • Text uses .sp; containers use heightIn(min=)
  • Layouts scale gracefully at 2x font
  • Modifier.padding(start, end) + AutoMirrored icons
  • Decorative images: contentDescription = null
  • Every screen tested with TalkBack before merge

Key takeaways

Practice exercises

  1. 01

    TalkBack one flow

    Enable TalkBack. Walk through your primary journey (sign-in → main action → success) using only gestures. Fix every confusing announcement.

  2. 02

    Audit on screens

    Add performAccessibilityAudit() to 5 screen tests. Fix every violation.

  3. 03

    Large-font preview

    Add @Preview(fontScale = 2f) to 10 screens. Fix every clipped layout.

  4. 04

    Custom action

    For a swipeable row (archive/delete), add CustomAccessibilityAction so TalkBack users can trigger both actions.

  5. 05

    Contrast audit

    For every custom color pair in your app, compute contrast ratio. Any below 4.5:1 gets updated to meet WCAG AA.

Next

Return to Module 19 Overview or continue to Module 20 — Career & Interview Prep.