Accessibility in Jetpack Compose: Building Apps Everyone Can Use

Make your Compose UI accessible to screen readers, switch devices, and keyboard users.

Introduction

Accessibility ensures your app works for users with disabilities. Jetpack Compose provides powerful accessibility primitives. This guide covers screen reader support, touch targets, focus management, and automated testing.

Screen Reader Fundamentals

Use contentDescription for images and icons. Use semantics {} block for custom components. Set heading levels with Role.Header. Provide meaningful labels, not 'button' or 'image'.

Touch Target Requirements

Minimum 48x48dp touch targets per Material Design. Use combinedClickable for multiple actions. Ensure adequate spacing between targets. Test with switch control.

Focus Management

Use focusRequester for programmatic focus. Handle focus traversal order. Provide visible focus indicators. Support keyboard navigation.

Automated Accessibility Testing

Use Accessibility Scanner for manual audits. Use Espresso with checkMatchesSemanticContent for automated tests. Integrate accessibility checks into CI pipeline.

Frequently Asked Questions

How do I test accessibility?

Enable TalkBack and navigate your app. Use Accessibility Scanner app for automated suggestions. Test with switch control and keyboard navigation. Include users with disabilities in testing.

What's the most common accessibility mistake?

Missing content descriptions on icons and images. Screen reader users hear 'unlabeled button' instead of meaningful descriptions.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:38:20
UP00:01
MOD · ARTICLE · ACCESSIBILITYS/N · AX-JETPACPUBLISHED
AccessibilityFeb 20, 202610 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

Over 1 billion people worldwide live with some form of disability. On Android, that means users relying on TalkBack (screen reader), Switch Access (physical switches), Voice Access, and magnification gestures. If your app does not work with these tools, you are excluding a significant portion of your potential audience. Beyond the ethical imperative, accessibility is increasingly a legal requirement. The EU Accessibility Act takes effect in June 2025, and ADA lawsuits against app developers have risen sharply in the US. The WCAG 2.1 guidelines set the global standard that most regulations reference. Google Play also factors accessibility into app quality reviews. The good news: Compose has accessibility built into its foundation. Material 3 components come with proper semantics by default. The work is making sure your custom components and screens do not break what the framework provides.

Semantics: How Compose Communicates with Accessibility Services

Every composable contributes to a semantics tree -- a parallel structure to the UI tree that accessibility services read to understand your screen. Material components set semantics automatically: a Button announces as a button, a TextField announces as a text field. For custom composables, you need to set semantics explicitly using the Modifier.semantics block. The most important properties are contentDescription (what TalkBack reads), role (button, checkbox, image, etc.), and stateDescription (checked, expanded, etc.).
kotlin
// Custom icon button needs explicit semantics
@Composable
fun FavoriteButton(
    isFavorite: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    IconButton(
        onClick = onClick,
        modifier = modifier.semantics {
            contentDescription = if (isFavorite)
                "Remove from favorites"
            else "Add to favorites"

            role = Role.Button
            stateDescription = if (isFavorite)
                "Favorited" else "Not favorited"
        },
    ) {
        Icon(
            imageVector = if (isFavorite)
                Icons.Filled.Favorite
            else Icons.Outlined.FavoriteBorder,
            contentDescription = null, // Handled by parent
            tint = if (isFavorite)
                MaterialTheme.colorScheme.primary
            else MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

// Decorative images should be hidden from TalkBack
@Composable
fun DecorativeWave(modifier: Modifier = Modifier) {
    Image(
        painter = painterResource(R.drawable.wave_bg),
        contentDescription = null, // null hides from a11y
        modifier = modifier.semantics {
            invisibleToUser()
        },
    )
}

Touch Target Sizes and Spacing

WCAG 2.1 Level AA requires a minimum touch target of 44x44dp. Material 3 components meet this by default, but custom components and tightly packed layouts often violate it. Compose provides Modifier.minimumInteractiveComponentSize() which enforces a 48x48dp minimum touch area (Material recommendation) without changing the visual size. Apply it to any interactive element that appears smaller than 48dp.
kotlin
// Too small: 24dp icon with no padding
// TalkBack users and users with motor impairments
// will struggle to tap this reliably
@Composable
fun SmallCloseButton(onClick: () -> Unit) {
    // BAD: Visual and touch target are both 24dp
    Icon(
        Icons.Default.Close,
        contentDescription = "Close",
        modifier = Modifier
            .size(24.dp)
            .clickable { onClick() },
    )
}

// Fixed: Visual is 24dp, touch target is 48dp
@Composable
fun AccessibleCloseButton(onClick: () -> Unit) {
    IconButton(
        onClick = onClick,
        modifier = Modifier.minimumInteractiveComponentSize(),
    ) {
        Icon(
            Icons.Default.Close,
            contentDescription = "Close",
            modifier = Modifier.size(24.dp),
        )
    }
}

// For rows of items, ensure adequate spacing
@Composable
fun ActionRow(
    onEdit: () -> Unit,
    onDelete: () -> Unit,
    onShare: () -> Unit,
) {
    Row(
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        IconButton(onClick = onEdit) {
            Icon(Icons.Default.Edit,
                contentDescription = "Edit")
        }
        IconButton(onClick = onDelete) {
            Icon(Icons.Default.Delete,
                contentDescription = "Delete")
        }
        IconButton(onClick = onShare) {
            Icon(Icons.Default.Share,
                contentDescription = "Share")
        }
    }
}

Heading Hierarchy and Screen Structure

TalkBack users navigate by headings -- they swipe up/down to jump between sections, similar to how sighted users scan a page visually. If your screen has no heading semantics, TalkBack users must listen to every element sequentially to find what they need. Mark section titles as headings using Modifier.semantics { heading() }. Maintain a logical hierarchy: one main heading per screen, sub-headings for sections.
kotlin
@Composable
fun ProfileScreen(user: User) {
    LazyColumn {
        // Screen title -- heading level
        item {
            Text(
                text = "Profile",
                style = MaterialTheme.typography.headlineMedium,
                modifier = Modifier
                    .padding(16.dp)
                    .semantics { heading() },
            )
        }

        // Section heading
        item {
            Text(
                text = "Personal Information",
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier
                    .padding(horizontal = 16.dp, vertical = 8.dp)
                    .semantics { heading() },
            )
        }

        item { ProfileField("Name", user.name) }
        item { ProfileField("Email", user.email) }

        // Another section heading
        item {
            Text(
                text = "Preferences",
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier
                    .padding(horizontal = 16.dp, vertical = 8.dp)
                    .semantics { heading() },
            )
        }

        item {
            SwitchPreference(
                label = "Dark mode",
                checked = user.darkMode,
                onCheckedChange = { /* toggle */ },
            )
        }
    }
}

Focus Management and Keyboard Navigation

Users navigating with a keyboard, D-pad, or Switch Access rely on focus order and focus indicators. Compose handles focus order automatically based on layout position, but custom layouts or overlays can disrupt it. Use FocusRequester to move focus programmatically when screens change -- for example, moving focus to an error message after form validation fails, or to the first item in a list after it loads. Trap focus inside dialogs and bottom sheets so users cannot accidentally interact with content behind them.
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 errorFocusRequester = remember { FocusRequester() }
    val passwordFocusRequester = remember { FocusRequester() }

    // Move focus to error message when it appears
    LaunchedEffect(error) {
        if (error != null) {
            errorFocusRequester.requestFocus()
        }
    }

    Column(modifier = Modifier.padding(16.dp)) {
        // Error announcement
        error?.let { msg ->
            Text(
                text = msg,
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier
                    .focusRequester(errorFocusRequester)
                    .semantics {
                        liveRegion = LiveRegionMode.Assertive
                    },
            )
        }

        OutlinedTextField(
            value = email,
            onValueChange = { email = it },
            label = { Text("Email") },
            keyboardOptions = KeyboardOptions(
                imeAction = ImeAction.Next,
            ),
            keyboardActions = KeyboardActions(
                onNext = {
                    passwordFocusRequester.requestFocus()
                },
            ),
        )

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

        Button(
            onClick = { onSubmit(email, password) },
            modifier = Modifier.fillMaxWidth(),
        ) {
            Text("Sign In")
        }
    }
}

Automated Accessibility Testing

Manual testing with TalkBack is essential but not scalable. Use the Accessibility Scanner (a standalone Google app) for quick audits and Espresso accessibility checks for CI integration. Compose UI tests can assert on semantics nodes directly. Check that every interactive element has a contentDescription, every heading is marked, and touch targets meet the minimum size. Run these tests in CI to catch regressions before they ship.
kotlin
@RunWith(AndroidJUnit4::class)
class AccessibilityTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun allButtons_haveContentDescription() {
        composeRule.setContent {
            MyAppTheme { HomeScreen() }
        }

        // Find all clickable nodes
        composeRule
            .onAllNodes(hasClickAction())
            .fetchSemanticsNodes()
            .forEach { node ->
                val desc = node.config.getOrNull(
                    SemanticsProperties.ContentDescription
                )
                val text = node.config.getOrNull(
                    SemanticsProperties.Text
                )

                // Every clickable must have either
                // contentDescription or visible text
                assertTrue(
                    "Clickable node missing accessible label",
                    desc != null || text != null,
                )
            }
    }

    @Test
    fun touchTargets_meetMinimumSize() {
        composeRule.setContent {
            MyAppTheme { HomeScreen() }
        }

        composeRule
            .onAllNodes(hasClickAction())
            .fetchSemanticsNodes()
            .forEach { node ->
                val bounds = node.boundsInRoot
                val width = bounds.width
                val height = bounds.height

                assertTrue(
                    "Touch target too small:" +
                    " ${width}x${height}dp",
                    width >= 44f && height >= 44f,
                )
            }
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Set contentDescription, role, and stateDescription on every custom interactive composable.
  2. 2Use Modifier.minimumInteractiveComponentSize() to ensure 48dp touch targets without changing visual size.
  3. 3Mark section titles with Modifier.semantics { heading() } so TalkBack users can navigate by headings.
  4. 4Manage focus programmatically: move focus to errors, trap focus in dialogs, support keyboard ImeAction flow.
  5. 5Run automated accessibility tests in CI to catch missing labels and undersized touch targets.
  6. 6Material 3 components have accessibility built in -- the work is making sure custom components match.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

How do I test accessibility?

Enable TalkBack and navigate your app. Use Accessibility Scanner app for automated suggestions. Test with switch control and keyboard navigation. Include users with disabilities in testing.

What's the most common accessibility mistake?

Missing content descriptions on icons and images. Screen reader users hear 'unlabeled button' instead of meaningful descriptions.

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