SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:59
UP00:01
MOD · ARTICLE · ACCESSIBILITYS/N · AX-JETPACPUBLISHED
All Articles
Accessibility · Updated 10 MIN

Accessibility in Jetpack Compose: Building Apps Everyone Can Use

Make Compose UI accessible to screen readers, switch devices, and keyboard users: semantics, content descriptions, touch targets, focus, and automated tests.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Accessibility Is Not Optional

The World Health Organization's figure is the place to start: "An estimated 1.3 billion people experience significant disability. This represents 16% of the world's population, or 1 in 6 of us" (WHO). On Android those users reach your app through TalkBack, Switch Access, Voice Access, a keyboard or a larger font size. An app that ignores them does not fail gracefully. It is unusable. It is also regulated. The European Accessibility Act has applied since 28 June 2025, and what it means for a shipped app is covered in the EAA guide. The technical yardstick most rules point to is WCAG. The earlier version of this article got three things wrong, and they are corrected below: it described the Act as upcoming, it cited a WCAG touch target requirement that WCAG does not contain, and its sample test compared pixels against a number meant as dp. It also made two claims, about lawsuit trends and Play review criteria, that had no source. Those are gone. The good news holds: Compose builds accessibility in. Material components carry the right semantics by default. The work is in what you build yourself.

Semantics: How Compose Communicates with Accessibility Services

Accessibility services do not see pixels. They read the semantics tree, a description of each element's meaning: its text, its role, its state, the actions it supports. Most of that is already there. The API defaults guide explains that "Material components, like Button or Text, and actionable behaviors, like clickable or toggleable, come with other predefined semantics that describe their intrinsic behavior". A Button is announced as a button without your help, and clickable and toggleable "merge their children's semantics by default, so that the component is treated as one logical entity". What the framework cannot know is what a picture means. "When you define an Image or Icon composable, there is no automatic way for the Android framework to understand what the app is displaying. You need to pass a textual description of the graphic element." So the rule for graphics has two halves: - Meaningful: set contentDescription to what the control does, in the user's language. - Decorative: pass contentDescription = null. That is the documented way to say an image has no semantic meaning. The earlier sample here added invisibleToUser() on top of a null description. The null was already enough, and that function is now deprecated: "Use hideFromAccessibility() instead" (reference). It also set role = Role.Button on an IconButton, which restates a default.
kotlin
// The description says what the control does in its current state.
// IconButton already supplies the button role and click action.
@Composable
fun FavoriteButton(
    isFavorite: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    IconButton(onClick = onClick, modifier = modifier) {
        Icon(
            imageVector = if (isFavorite)
                Icons.Filled.Favorite
            else Icons.Outlined.FavoriteBorder,
            contentDescription = if (isFavorite)
                stringResource(R.string.remove_from_favorites)
            else stringResource(R.string.add_to_favorites),
        )
    }
}

// Decorative image: null means "no semantic meaning"
@Composable
fun DecorativeWave(modifier: Modifier = Modifier) {
    Image(
        painter = painterResource(R.drawable.wave_bg),
        contentDescription = null,
        modifier = modifier,
    )
}

// A subtree that should not be reachable at all
Box(Modifier.semantics { hideFromAccessibility() }) {
    /* purely visual duplicate of content announced elsewhere */
}

What touch target size does Android require?

48dp. That number comes from Android's guidance, not from WCAG, and the earlier version of this article blurred the two. It said WCAG 2.1 Level AA requires a 44x44dp target. WCAG says something else:
SourceCriterionLevelMinimum
WCAG 2.12.5.5 Target SizeAAA"at least 44 by 44 CSS pixels"
WCAG 2.22.5.8 Target Size (Minimum)AA"at least 24 by 24 CSS pixels"
AndroidMaterial guidancen/a48dp
The 44 pixel figure is the AAA criterion. The AA requirement, new in WCAG 2.2, is 24. Android asks for more than either: "make sure to set the minimum size to 48dp to correctly follow the Material Design accessibility guidelines". Build to 48dp and you clear all three. Compose helps in two ways. Material components such as Checkbox, RadioButton and Switch set the minimum size internally when they can receive user actions. And for a small element with clickable, "Compose still increases the touch target size. It does so by expanding the touch target size outside of the boundaries of the composable." That second behaviour is a safety net, not a layout. The expanded area is invisible and can overlap a neighbour. For your own small controls, reserve the space with Modifier.minimumInteractiveComponentSize(), which "Reserves at least 48.dp in size to disambiguate touch interactions if the element would measure smaller" (reference). Put the size in the design system once and no screen has to remember it.
kotlin
// A 24dp icon made clickable. Compose expands the touch area
// beyond the bounds, but the layout still only reserves 24dp,
// so neighbours can sit inside the expanded area.
@Composable
fun SmallCloseButton(onClick: () -> Unit) {
    Icon(
        Icons.Default.Close,
        contentDescription = stringResource(R.string.close),
        modifier = Modifier
            .size(24.dp)
            .clickable(onClick = onClick),
    )
}

// Custom control: reserve 48dp in layout, keep the 24dp visual
@Composable
fun CloseChip(onClick: () -> Unit) {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .minimumInteractiveComponentSize()
            .clickable(
                onClickLabel = stringResource(R.string.close),
                role = Role.Button,
                onClick = onClick,
            ),
    ) {
        Icon(
            Icons.Default.Close,
            contentDescription = null, // the clickable carries the label
            modifier = Modifier.size(24.dp),
        )
    }
}

// Where a Material control fits, use it and describe the icon
@Composable
fun AccessibleCloseButton(onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            Icons.Default.Close,
            contentDescription = stringResource(R.string.close),
        )
    }
}

Heading Hierarchy and Screen Structure

A sighted user scans a screen and jumps to the section they want. A screen reader user without headings hears every element in order. The semantics guide puts it plainly: "Users with accessibility needs can have difficulties navigating such a screen easily. To improve the navigation experience, some accessibility services allow for easier navigation directly between sections or headings." You opt in per element with Modifier.semantics { heading() }. Mark the screen title and each section title. Do not mark text as a heading because it is large; mark it because content follows under it. On a wide screen, where adaptive layouts put two panes side by side, give each pane its own heading so the structure survives the change in layout.
kotlin
@Composable
fun ProfileScreen(user: User) {
    LazyColumn {
        item {
            Text(
                text = stringResource(R.string.profile),
                style = MaterialTheme.typography.headlineMedium,
                modifier = Modifier
                    .padding(16.dp)
                    .semantics { heading() },
            )
        }

        item { SectionHeading(stringResource(R.string.personal_information)) }
        item { ProfileField(stringResource(R.string.name), user.name) }
        item { ProfileField(stringResource(R.string.email), user.email) }

        item { SectionHeading(stringResource(R.string.preferences)) }
        item {
            SwitchPreference(
                label = stringResource(R.string.dark_mode),
                checked = user.darkMode,
                onCheckedChange = { /* toggle */ },
            )
        }
    }
}

@Composable
private fun SectionHeading(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.titleMedium,
        modifier = Modifier
            .padding(horizontal = 16.dp, vertical = 8.dp)
            .semantics { heading() },
    )
}

Focus, Keyboards and Announcing Changes

Keyboard, D-pad and switch users move by focus. Compose orders focus from the layout, and you step in when a flow needs focus somewhere specific. The focus guide gives the recipe: create and remember a FocusRequester, attach it with the focusRequester modifier, and "Call requestFocus() inside an event callback (such as onClick) or a coroutine scope." A change the user did not cause, such as a validation error appearing, needs to be announced. That is a live region, and the mode matters. The guidance: "You should use liveRegionMode.Polite in most cases where users' attention should only briefly be drawn to alerts or important changing content on-screen. You should use liveRegion.Assertive sparingly to avoid disruptive feedback." The earlier sample here marked a form error as assertive and tried to move focus onto a plain Text, which is not focusable. The version below announces the error politely and moves focus only between the fields that can take it.
kotlin
@Composable
fun LoginForm(onSubmit: (String, String) -> Unit) {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var error by remember { mutableStateOf<String?>(null) }

    val passwordFocus = remember { FocusRequester() }

    Column(modifier = Modifier.padding(16.dp)) {
        // Announced when it appears, without interrupting speech
        error?.let { message ->
            Text(
                text = message,
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier.semantics {
                    liveRegion = LiveRegionMode.Polite
                },
            )
        }

        OutlinedTextField(
            value = email,
            onValueChange = { email = it },
            label = { Text(stringResource(R.string.email)) },
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
            keyboardActions = KeyboardActions(
                onNext = { passwordFocus.requestFocus() },
            ),
        )

        OutlinedTextField(
            value = password,
            onValueChange = { password = it },
            label = { Text(stringResource(R.string.password)) },
            visualTransformation = PasswordVisualTransformation(),
            modifier = Modifier.focusRequester(passwordFocus),
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
            keyboardActions = KeyboardActions(
                onDone = { onSubmit(email, password) },
            ),
        )

        Button(
            onClick = { onSubmit(email, password) },
            modifier = Modifier.fillMaxWidth(),
        ) {
            Text(stringResource(R.string.sign_in))
        }
    }
}

Automated Accessibility Testing

Manual passes with TalkBack do not scale, and they are not repeatable. Since Compose 1.8.0 the test library can run the Accessibility Test Framework for you, "the same underlying framework that powers Accessibility Scanner and accessibility checks in Espresso" (Android). The setup is three steps in the guide's words: "add the ui-test-junit4-accessibility dependency, call enableAccessibilityChecks() in the AndroidComposeTestRule, and trigger an action or tryPerformAccessibilityChecks". The checks cover more than labels. "Other available checks look for accessibility issues with color contrast, small touch target size, or elements' traversal order." This replaces the hand-rolled test this article used to show. That test read boundsInRoot, which is in pixels, and compared it with 44. At a density of 3 a 24dp control is 72 pixels wide, so the test passed on exactly the controls it existed to catch. Use the framework's check and run it in CI on every pull request.
kotlin
// build.gradle.kts
// androidTestImplementation("androidx.compose.ui:ui-test-junit4-accessibility")

@RunWith(AndroidJUnit4::class)
class AccessibilityTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun homeScreen_passesAccessibilityChecks() {
        composeTestRule.setContent {
            MyAppTheme { HomeScreen() }
        }

        composeTestRule.enableAccessibilityChecks()

        // Any action runs the checks. This one runs them on the root.
        composeTestRule.onRoot().tryPerformAccessibilityChecks()
    }

    @Test
    fun sectionTitles_areHeadings() {
        composeTestRule.setContent {
            MyAppTheme { ProfileScreen(testUser) }
        }

        composeTestRule
            .onNodeWithText("Preferences")
            .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Heading))
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Give every meaningful icon and image a contentDescription, and pass null for decorative ones.
  2. 2Android asks for 48dp touch targets. WCAG 2.1 44px is a AAA criterion and WCAG 2.2 AA asks for 24px.
  3. 3Reserve space for small custom controls with Modifier.minimumInteractiveComponentSize().
  4. 4Mark screen and section titles with Modifier.semantics { heading() } so services can jump between them.
  5. 5Announce changes with a polite live region. Keep assertive for time-sensitive alerts.
  6. 6From Compose 1.8.0, enableAccessibilityChecks() runs the Accessibility Test Framework inside your UI tests.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

How do I test accessibility in a Compose app?

Do both kinds. Walk the main flows with TalkBack and a keyboard, because only a person can judge whether an announcement makes sense. Then add the ui-test-junit4-accessibility dependency and call enableAccessibilityChecks() in your Compose tests, which checks labels, contrast, touch target size and traversal order on every run.

What is the most common accessibility mistake in Compose?

A meaningful icon with no content description. Compose cannot know what a picture means, so an icon button without a description reaches a screen reader as an unlabelled control. The opposite mistake is nearly as common: describing decorative images, which makes the user listen to noise. Pass null for those.

Does WCAG require 44dp touch targets?

No. WCAG 2.1 criterion 2.5.5 asks for 44 by 44 CSS pixels at Level AAA, the strictest level. WCAG 2.2 added criterion 2.5.8 at Level AA with a 24 by 24 CSS pixel minimum. Android guidance is stricter than both and asks for 48dp, which is the number to build to.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA