Zygote
UI toolkitsintermediate

Accessibility

Accessibility is not a feature you add at the end. It is the set of semantics your UI publishes so that a screen reader, a switch or a keyboard can operate it — and Compose changes how you publish them.

11 minAPI 1–37 · 17interactivequizverified 2026-10-27

A screen reader does not see pixels. It reads a tree of nodes — each of labels, roles, states and actions — and if your UI does not publish that tree, it does not exist for a large fraction of users. If your UI does not publish that tree, it does not exist for a large fraction of users — and for everyone using a keyboard, a switch, or voice control.

TL;DR#

  • TalkBack and other services read semantics, not pixels. A drawing without a description is silent.1
  • Content descriptions label non-text elements. Text elements already have a label; do not duplicate it.
  • Touch targets should be at least 48 dp; Material components already enforce this.1
  • Compose publishes semantics through Modifier.semantics; the system through AccessibilityNodeInfo and View properties.
  • Accessibility benefits everyone: contrast, target size, focus order, and predictable labels are general usability.

Mental model#

Think of a museum audio guide.

The guide does not describe the paint. It gives each exhibit a title, a short description, its location, and what you can do with it ("press 4 to hear more"). A visitor using the guide experiences the same exhibition as a sighted visitor — differently, but completely.

Where the analogy breaks#

An audio guide is optional. In Android, the semantics tree is also how keyboard navigation, switch access and UI automation work. If the tree is wrong, the app is not just less accessible; it is untestable by Espresso, unusable with a physical keyboard, and invisible to the accessibility scanner that Play uses in pre-launch reports.

The big picture#

Loading diagram…
Text description of this diagram

The mapping table from View-system concepts to Compose equivalents, filtered to show the accessibility-relevant rows. A TextView maps to Text, which publishes its text as a label automatically. An ImageView maps to Image, which needs a contentDescription when it conveys meaning. A Button maps to Button, which publishes a click action. A custom View with onDraw maps to a Canvas composable, and neither publishes anything unless the developer adds semantics. The table shows that the toolkit does not determine accessibility; the semantics you attach do.

Deep dive#

What a semantics node contains#

Every node a service sees has, at minimum:

PropertyExample
label"Delete note"
role / classButton, CheckBox, Image
statechecked, disabled, selected
actionsclick, scroll forward, set progress
boundswhere it is on screen
traversal orderwhere it sits in the reading order

If a control has a label but no role, a screen reader reads it as text. If it has a role but no action, it cannot be activated. Both are common.

The View system#

For standard views, most of this is automatic:

<ImageButton
    android:id="@+id/delete"
    android:layout_width="48dp"
    android:layout_height="48dp"
    android:src="@drawable/ic_delete"
    android:contentDescription="@string/delete_note" />

Rules that follow:

  • contentDescription on anything that conveys meaning without text. An icon button needs one; a decorative image should have contentDescription="@null" (or importantForAccessibility="no").
  • Never put a content description on a TextView that already has text. TalkBack reads both and the result is a stutter.
  • 48 dp minimum touch target. minWidth/minHeight or touchDelegate for small icons.

For a custom view, you publish the node yourself:

class RatingBarView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : View(context, attrs) {
 
    var rating: Int = 0
        set(value) {
            field = value.coerceIn(0, 5)
            contentDescription = context.getString(R.string.rating_value, field)
            invalidate()
        }
 
    override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
        super.onInitializeAccessibilityNodeInfo(info)
        info.className = RatingBarView::class.java.name
        info.contentDescription = context.getString(R.string.rating_value, rating)
        info.isClickable = true
        info.addAction(
            AccessibilityNodeInfo.AccessibilityAction(
                AccessibilityNodeInfo.ACTION_CLICK,
                context.getString(R.string.increase_rating),
            )
        )
    }
}

Compose#

Compose merges semantics from the composables in a subtree, which is usually what you want:

@Composable
fun NoteRow(note: Note, onOpen: () -> Unit) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClickLabel = "Open note") { onOpen() }
            .semantics(mergeDescendants = true) {
                contentDescription = "${note.title}, ${note.author}"
            }
            .padding(16.dp),
    ) {
        Text(note.title, style = MaterialTheme.typography.titleMedium)
        Text(note.author, style = MaterialTheme.typography.bodySmall)
    }
}

Three things to notice:

  • clickable publishes a click action and a role.
  • semantics(mergeDescendants = true) collapses the two Texts into one node, so TalkBack reads "Title, Author" once instead of twice.
  • onClickLabel gives the action a name that makes sense out of context.

For a purely visual element:

Canvas(modifier = Modifier.size(48.dp)) {
    // draw something
}

That is silent. It needs either a contentDescription or, if it is decorative, Modifier.clearAndSetSemantics { }.

Focus order and traversal#

The default order is the composition order for Compose and the view hierarchy order for Views. When that does not match the visual reading order, fix the tree rather than the order:

  • Views: android:nextFocusForward, android:accessibilityTraversalBefore/After.
  • Compose: Modifier.traversalIndex on the elements that need reordering, or restructure so the declaration order matches the reading order.

Restructuring is almost always better. A traversal override is a patch on a layout that was already confusing.

Colour, contrast and text size#

Three rules that need no API:

  • Contrast: 4.5:1 for body text, 3:1 for large text and UI components. Material's tonal palettes are designed around this, which is a good reason to use them rather than hand-picked colours.
  • Do not encode meaning in colour alone. An error state needs an icon or text, not just red.
  • Respect font scale. Use sp for text, and test at 200%. A layout that clips at 200% is broken for a large number of users.
// Compose: text scales automatically when you use sp.
Text("Total", fontSize = 16.sp)   // scales
Text("Total", fontSize = 16.dp.toSp())  // does not scale with the user's setting

Testing accessibility#

Three layers, from cheapest to most valuable:

  1. The Accessibility Scanner app — an on-device audit that flags missing labels, small targets and low contrast.
  2. Espresso / Compose test assertions on semantics — a test that finds a node by content description proves the label exists.
  3. Manual TalkBack walkthrough — the only way to catch a confusing reading order or a missing state announcement.
@Test
fun deleteButton_hasLabel() {
    composeTestRule.setContent { NoteRow(note = sampleNote, onOpen = {}) }
    composeTestRule
        .onNodeWithContentDescription("Delete note")
        .assertIsDisplayed()
        .assertHasClickAction()
}

That test is worth more than a lint rule: it fails when somebody removes the label, which is exactly when accessibility regresses.

Accessibility and large screens#

Android 16 and 17's adaptive-first push interacts with accessibility in a specific way: on a tablet or desktop window, a keyboard and a screen reader may both be active. Focus order, visible focus indicators and predictable back behaviour matter more, not less.2

// A visible focus indicator is not optional with a keyboard.
Modifier
    .focusable()
    .onFocusChanged { state -> hasFocus = state.isFocused }
    .border(2.dp, if (hasFocus) MaterialTheme.colorScheme.primary else Color.Transparent)

Live regions and announced state#

A screen reader user often cannot see that something changed. Compose's liveRegion and the View system's AccessibilityEvent both exist to announce it:

Text(
    text = "Saved",
    modifier = Modifier.semantics {
        liveRegion = LiveRegionMode.Polite
    },
)

Polite waits for the reader to finish the current sentence; Assertive interrupts. Use assertive only for errors that invalidate what the user is doing — an interruption is a cost, and a screen that announces every keystroke is unusable.

What automation depends on#

Espresso finds views by ID and text; Compose tests find nodes by semantics. Both are reading the same tree a screen reader reads. That means an accessibility fix is usually also a test fix: a floating action button with no content description cannot be found by onNodeWithContentDescription, and an icon-only toolbar item with no label is invisible to both.

Large text, focus and the physical world#

Accessibility settings are not only for screen readers. Three that affect layout:

  • Font scale (up to 200%) changes the height of every text run. Text that is in sp scales; text sized in dp does not, and looks broken next to text that does.
  • Display size changes the whole layout, effectively a lower density. A layout with hard-coded dimensions that only just fits will clip.
  • Bold text and high contrast change glyph metrics slightly; a label that exactly fits in a button will ellipsise.
// Compose: the font scale is part of the density, so sp scales automatically.
Text("Total", fontSize = 16.sp)
 
// A fixed height will clip at large font scales.
Box(modifier = Modifier.height(24.dp)) { Text("Total") }

The practical fix is to let containers size to their content and to test at 200% before shipping. That single test catches most of the layout bugs that accessibility users report.

The scanner, and what it cannot see#

The Accessibility Scanner catches missing labels, small touch targets and low contrast. It cannot catch:

  • A reading order that is technically valid but confusing.
  • A control whose state change is not announced.
  • A custom gesture with no keyboard equivalent.
  • A label that is present but meaningless ("button", "image").

That is why the manual TalkBack pass is not optional. The scanner finds the mechanical problems; a five-minute walkthrough with a screen reader finds the ones that make the app unusable despite passing every automated check.

The business case, briefly#

Accessibility improvements are general usability improvements. Larger touch targets help everyone in a moving vehicle. Contrast helps outdoors. Keyboard support helps power users. Predictable labels help automation and testing. The reason to do it is not compliance; it is that the fixes are the same fixes that make an app feel considered.

How it evolved#

Interface changes16 releases
APIVersionWhat changed
11.0
  • Pull-down notification shade
  • Home screen with widgets
31.5 Cupcake
  • Home screen widgets and folders
92.3 Gingerbread
  • New UI with dark theme and green accents
113.0 Honeycomb
  • The Holo design language arrives
123.1 Honeycomb
  • Resizable widgets
164.1 Jelly Bean
  • Project Butter: vsync-driven, smooth UI
  • Expandable notifications
204.4W KitKat (Wear)
  • Voice-driven card UI
215.0 Lollipop
  • Material Design
  • Notifications on the lock screen
247.0 Nougat
  • Quick Settings tiles
  • Direct Reply in notifications
268.0 Oreo
  • Notification channels
289 Pie
  • Dark theme groundwork
2910
  • System-wide dark theme
3011
  • Conversation notifications and bubbles
  • Media controls in Quick Settings
3112
  • Material You dynamic colour
3313 Tiramisu
  • Themed app icons
  • Runtime notification permission
3616 Baklava
  • Material 3 Expressive
  • Live Updates notifications

Two inflection points: Android 4.1's accessibility focus and gestures, and Android 8.0's AccessibilityService improvements that made TalkBack substantially better. Compose then changed how semantics are authored, not what services consume.

Under the hood#

Curiosity corner#

  • TalkBack is an app, not part of the framework. It is an AccessibilityService, which is why it can be updated through Play and why other services can coexist with it.
  • A content description on a TextView is read twice by some services, because the text is already the label.
  • The 48 dp touch target is a guideline with real numbers behind it: roughly a fingertip's contact area, with margin for error.

Try it#

Check whether an accessibility service is enabled

adb shell settings get secure enabled_accessibility_services

Dump the semantics of the current screen

adb shell dumpsys accessibility

Check yourself#

Check yourself

1 / 12

What is the difference between `invalidate()` and `requestLayout()`?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsAccessibility on Androiddeveloper.android.comaccessed 2026-10-27↩
  2. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
  3. docsCreate custom viewsdeveloper.android.comaccessed 2026-10-27↩
  4. docsCreate a view layoutdeveloper.android.comaccessed 2026-10-27↩
  5. docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
  6. docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩