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.
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 throughAccessibilityNodeInfoandViewproperties. - 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#
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:
| Property | Example |
|---|---|
| label | "Delete note" |
| role / class | Button, CheckBox, Image |
| state | checked, disabled, selected |
| actions | click, scroll forward, set progress |
| bounds | where it is on screen |
| traversal order | where 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:
contentDescriptionon anything that conveys meaning without text. An icon button needs one; a decorative image should havecontentDescription="@null"(orimportantForAccessibility="no").- Never put a content description on a
TextViewthat already has text. TalkBack reads both and the result is a stutter. - 48 dp minimum touch target.
minWidth/minHeightortouchDelegatefor 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:
clickablepublishes a click action and a role.semantics(mergeDescendants = true)collapses the twoTexts into one node, so TalkBack reads "Title, Author" once instead of twice.onClickLabelgives 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.traversalIndexon 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
spfor 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 settingTesting accessibility#
Three layers, from cheapest to most valuable:
- The Accessibility Scanner app — an on-device audit that flags missing labels, small targets and low contrast.
- Espresso / Compose test assertions on semantics — a test that finds a node by content description proves the label exists.
- 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
spscales; text sized indpdoes 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#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 3 | 1.5 Cupcake |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 12 | 3.1 Honeycomb |
|
| 16 | 4.1 Jelly Bean |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 33 | 13 Tiramisu |
|
| 36 | 16 Baklava |
|
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#
- AccessibilityNodeInfo.javathe node description every service reads
- View.javaonInitializeAccessibilityNodeInfo and the contentDescription property
- the system service that routes accessibility events
- AccessibilityManager.javawhether an accessibility service is enabled
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#
adb shell settings get secure enabled_accessibility_services
adb shell dumpsys accessibility
Check yourself#
Check yourself
What is the difference between `invalidate()` and `requestLayout()`?
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.
- docsAccessibility on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
- docsCreate custom viewsdeveloper.android.comaccessed 2026-10-27↩
- docsCreate a view layoutdeveloper.android.comaccessed 2026-10-27↩
- docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩