Accessibility in Compose

Apps written in Compose should support accessibility for users with different needs. Accessibility services are used to transform what’s shown on screen to a more fitting format for a user with a specific need. To support accessibility services, apps use APIs in the Android framework to expose semantic information about their UI elements. The Android framework will then inform accessibility services about this semantic information. Each accessibility service can choose how best to describe the app to the user. Android provides several accessibility services, including Talkback and Switch Access.

Semantics

Compose uses semantics properties to pass information to accessibility services. Semantics properties provide information about UI elements that are displayed to the user. Most built-in composables like Text and Button fill these semantics properties with information inferred from the composable and its children. Some modifiers like toggleable and clickable will also set certain semantics properties. However, sometimes the framework needs more information to understand how to describe a UI element to the user.

This document describes various situations in which you need to explicitly add extra information to a composable so it can be correctly described to the Android framework. It also explains how to replace the semantics information completely for a given composable. It assumes a basic understanding of accessibility in Android.

Common use cases

To help people with accessibility needs use your app successfully, your app should follow the best practices described on this page.

Consider minimum touch target sizes

Any on-screen element that someone can click, touch, or interact with should be large enough for reliable interaction. When sizing these elements, make sure to set the minimum size to 48dp to correctly follow the Material Design Accessibility Guidelines.

Material components—like Checkbox, RadioButton, Switch, Slider, and Surface—set this minimum size internally, but only when the component can receive user actions. For example, when a Checkbox has its onCheckedChange parameter set to a non-null value, it will include padding to have a width and height of at least 48dp.

@Composable
private fun CheckableCheckbox() {
    Checkbox(checked = true, onCheckedChange = {})
}

When the onCheckedChange parameter is set to null, the padding is not included, because the component cannot be interacted with directly.

@Composable
private fun NonClickableCheckbox() {
    Checkbox(checked = true, onCheckedChange = null)
}

When implementing selection controls like Switch, RadioButton, or Checkbox, you typically lift the clickable behavior to a parent container, set the click callback on the composable to null, and add a toggleable or selectable modifier to the parent composable.

@Composable
private fun CheckableRow() {
    MaterialTheme {
        var checked by remember { mutableStateOf(false) }
        Row(
            Modifier
                .toggleable(
                    value = checked,
                    role = Role.Checkbox,
                    onValueChange = { checked = !checked }
                )
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text("Option", Modifier.weight(1f))
            Checkbox(checked = checked, onCheckedChange = null)
        }
    }
}

When the size of a clickable composable is smaller than the minimum touch target size, Compose still increases the touch target size. It does so by expanding the touch target size outside of the boundaries of the composable.

In the following example we create a very small clickable Box. The touch target area is automatically expanded beyond the boundaries of the Box, so tapping next to the Box still triggers the click event.

@Composable
private fun SmallBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .size(1.dp)
        )
    }
}

To prevent possible overlap between touch areas of different composables, you should always aim to use a large enough minimum size for the composable. In our example that would mean using the sizeIn modifier to set the minimum size for the inner box:

@Composable
private fun LargeBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
        )
    }
}

Add click labels

You can use a click label to add semantic meaning to the click behavior of a composable. Click labels describe what happens when the user interacts with the composable. Accessibility services use click labels to help describe the app to users with specific needs.

Set the click label by passing a parameter in the clickable modifier:

@Composable
private fun ArticleListItem(openArticle: () -> Unit) {
    Row(
        Modifier.clickable(
            // R.string.action_read_article = "read article"
            onClickLabel = stringResource(R.string.action_read_article),
            onClick = openArticle
        )
    ) {
        // ..
    }
}

Alternatively, if you don't have access to the clickable modifier, you can set the click label in the semantics modifier:

@Composable
private fun LowLevelClickLabel(openArticle: () -> Boolean) {
    // R.string.action_read_article = "read article"
    val readArticleLabel = stringResource(R.string.action_read_article)
    Canvas(
        Modifier.semantics {
            onClick(label = readArticleLabel, action = openArticle)
        }
    ) {
        // ..
    }
}

Describe visual elements

When you define an Image or Icon composable, there is no automatic way for the Android framework to understand what is being displayed. You need to pass a textual description of the visual element.

Imagine a screen where the user can share the current page with friends. This screen contains a clickable share icon:

A strip of clickable icons, with the

Based on the icon alone, the Android framework can’t figure out how to describe it to a visually impaired user. The Android framework needs an additional textual description of the icon.

The contentDescription parameter is used to describe a visual element. You should use a localized string, as this will be communicated to the user.

@Composable
private fun ShareButton(onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            imageVector = Icons.Filled.Share,
            contentDescription = stringResource(R.string.label_share)
        )
    }
}

Some visual elements are purely decorative and you might not want to communicate them to the user. When you set the contentDescription parameter to null, you indicate to the Android framework that this element does not have associated actions or state.

@Composable
private fun PostImage(post: Post, modifier: Modifier = Modifier) {
    val image = post.imageThumb ?: painterResource(R.drawable.placeholder_1_1)

    Image(
        painter = image,
        // Specify that this image has no semantic meaning
        contentDescription = null,
        modifier = modifier
            .size(40.dp, 40.dp)
            .clip(MaterialTheme.shapes.small)
    )
}

It is up to you to decide whether a given visual element needs a contentDescription. Ask yourself if the element conveys information that the user will need to perform their task. If not, it’s better to leave the description out.

Merge elements

Accessibility services like Talkback and Switch Access allow users to move focus across elements on the screen. It is important that elements are focused at the right granularity. If every single low-level composable in your screen is focused independently, a user will have to interact a lot to move across the screen. If elements are merged too aggressively, users might not understand which elements belong together.

When you apply a clickable modifier to a composable, Compose will automatically merge all elements it contains. This also holds for ListItem; elements within a list item will be merged and accessibility services will view them as one element.

It is possible to have a set of composables that form a logical group, but that group is not clickable or part of a list item. You’d still want accessibility services to view them as one element. For example, imagine a composable that shows a user’s avatar, their name, and some extra information:

A group of UI elements including a user's name. The name is selected.

You can tell Compose to merge these elements by using the mergeDescendants parameter in the semantics modifier. This way, accessibility services will select only the merged element, and all semantics properties of the descendants are merged.

@Composable
private fun PostMetadata(metadata: Metadata) {
    // Merge elements below for accessibility purposes
    Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
        Image(
            imageVector = Icons.Filled.AccountCircle,
            contentDescription = null // decorative
        )
        Column {
            Text(metadata.author.name)
            Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
        }
    }
}

Accessibility services will now focus on the whole container at once, merging their contents:

A group of UI elements including a user's name. All the elements are selected together.

Add custom actions

Take a look at the following list item:

A typical list item, containing an article title, author, and bookmark icon.

When you use a screen reader like Talkback to hear what’s displayed on the screen, it will first select the whole item, and then the bookmark icon.

The list item, with all the elements selected together.

The list item, with just the bookmark icon selected

In a long list, this can become very repetitive. A better approach would be to define a custom action that allows a user to bookmark the item. Keep in mind that you will also have to explicitly remove the behavior of the bookmark icon itself, to make sure it will not be selected by the accessibility service. This is done with the clearAndSetSemantics modifier:

@Composable
private fun PostCardSimple(
    /* ... */
    isFavorite: Boolean,
    onToggleFavorite: () -> Boolean
) {
    val actionLabel = stringResource(
        if (isFavorite) R.string.unfavorite else R.string.favorite
    )
    Row(
        modifier = Modifier
            .clickable(onClick = { /* ... */ })
            .semantics {
                // Set any explicit semantic properties
                customActions = listOf(
                    CustomAccessibilityAction(actionLabel, onToggleFavorite)
                )
            }
    ) {
        /* ... */
        BookmarkButton(
            isBookmarked = isFavorite,
            onClick = onToggleFavorite,
            // Clear any semantics properties set on this node
            modifier = Modifier.clearAndSetSemantics { }
        )
    }
}

Describe an element’s state

A composable can define a stateDescription for semantics which is used by the Android framework to read out the state that the composable is in. For example, a toggleable composable can be in either a “Checked” or an “Unchecked” state. In some cases, you might want to override the default state description labels that are used by Compose. You can do so by explicitly specifying the state description labels before defining a composable as toggleable:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
    val stateSubscribed = stringResource(R.string.subscribed)
    val stateNotSubscribed = stringResource(R.string.not_subscribed)
    Row(
        modifier = Modifier
            .semantics {
                // Set any explicit semantic properties
                stateDescription = if (selected) stateSubscribed else stateNotSubscribed
            }
            .toggleable(
                value = selected,
                onValueChange = { onToggle() }
            )
    ) {
        /* ... */
    }
}

Define headings

Apps sometimes show a lot of content on one screen, in a scrollable container. For example, a screen could show the full contents of an article that the user is reading:

Screenshot of a blog post, with the article text in a scrollable container.

Users with accessibility needs will have a hard time navigating such a screen. To aid navigation, you can indicate which elements are headings. In the example above, each subsection title could be defined as a heading for accessibility. Some accessibility services, like Talkback, allow users to navigate directly from heading to heading.

In Compose, you indicate that a composable is a heading by defining its semantics property:

@Composable
private fun Subsection(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier.semantics { heading() }
    )
}

Automated testing of accessibility properties

When customizing your app’s semantic properties, for example when following the use cases listed above, you can verify correctness and prevent regressions by using automated UI tests.

For example, to test that an element’s click label is set correctly, use the following code:

@Test
fun test() {
    composeTestRule
        .onNode(nodeMatcher)
        .assert(
            SemanticsMatcher("onClickLabel is set correctly") {
                it.config.getOrNull(SemanticsActions.OnClick)?.label == "My Click Label"
            }
        )
}

Creating custom low-level composables

A more advanced use case involves replacing certain Material components in your app with custom versions. In this scenario, it is vital that you keep accessibility considerations in mind. Say you’re replacing the Material Checkbox with your own implementation. It would be really easy to forget to add the triStateToggleable modifier, which handles the accessibility properties for this component.

As a rule of thumb, you should look at the implementation of the component in the Material library and mimic any accessibility behavior that you can find. Additionally, make heavy use of Foundation modifiers, as opposed to UI level modifiers, as these include accessibility considerations out of the box. Make sure to test your custom component implementation with multiple accessibility services to verify its behavior.

Modify traversal order with isTraversalGroup and traversalIndex

By default, accessibility screen reader behavior in a Compose app is implemented in expected reading order, which is usually left-to-right, then top-to-bottom. However, there are some types of app layouts where the algorithm can't determine the actual reading order without additional hints. In View-based apps, you could fix such issues using the traversalBefore and traversalAfter properties. Starting in Compose 1.5, Compose provides an equally flexible API, but with a new conceptual model.

isTraversalGroup and traversalIndex are semantic properties that let you control accessibility and TalkBack focus order in scenarios where the default sorting algorithm is not appropriate. isTraversalGroup identifies semantically important groups, while traversalIndex adjusts the order of individual elements within those groups. You can use isTraversalGroup alone, or with traversalIndex for further customization.

This page describes how to use isTraversalGroup and traversalIndex in your app to control screen reader traversal order.

Group elements with isTraversalGroup

isTraversalGroup is a boolean property that defines whether a semantics node is a traversal group. This type of node is one whose function is to serve as a boundary or border in organizing its children.

Setting isTraversalGroup = true on a node means that all children of that node are visited before moving to other elements. You can set isTraversalGroup on non-screen reader focusable nodes, such as Columns, Rows, or Boxes.

In this example, a snippet is modified to use isTraversalGroup. The snippet below emits four text elements. The left two elements belong to one CardBox element, while the right two elements belong to another CardBox element:

// CardBox() function takes in top and bottom sample text.
@Composable
fun CardBox(
    topSampleText: String,
    bottomSampleText: String,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        Column {
            Text(topSampleText)
            Text(bottomSampleText)
        }
    }
}

@Composable
fun TraversalGroupDemo() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is "
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
            topSampleText1,
            bottomSampleText1
        )
        CardBox(
            topSampleText2,
            bottomSampleText2
        )
    }
}

The code produces output similar to the following:

Layout with two columns of text, with the left column reading 'This
  sentence is in the left column' and the right column reading 'This sentence is on the right.'
Figure 1. A layout with two sentences (one in the left column and one in the right column).

Because no semantics have been set, the default behavior of the screen reader is to traverse elements from left to right and top to bottom. Because of this default, TalkBack reads out the sentence fragments in the wrong order:

"This sentence is in" → "This sentence is" → "the left column." → "on the right."

To order the fragments correctly, modify the original snippet to set isTraversalGroup to true:

@Composable
fun TraversalGroupDemo2() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is"
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
//      1,
            topSampleText1,
            bottomSampleText1,
            Modifier.semantics { isTraversalGroup = true }
        )
        CardBox(
//      2,
            topSampleText2,
            bottomSampleText2,
            Modifier.semantics { isTraversalGroup = true }
        )
    }
}

Because isTraversalGroup is set specifically on each CardBox, the CardBox boundaries are honored when sorting their elements. In this case, the left CardBox is read first, followed by the right CardBox.

Now, TalkBack reads out the sentence fragments in the correct order:

"This sentence is in" → "the left column." → "This sentence is" → "on the right."

Further customize traversal order with traversalIndex

traversalIndex is a float property that allows you to customize TalkBack traversal order. If simply grouping elements together is not enough for TalkBack to work correctly, you can use traversalIndex in conjunction with isTraversalGroup to further customize screen reader ordering.

The traversalIndex property has the following characteristics:

  • Elements with lower traversalIndex values are prioritized first.
  • Can be positive or negative.
  • The default value is 0f.
  • Only affects screen reader-focusable nodes, such as on-screen elements like Text or Buttons. For example, setting only traversalIndex on a Column would have no effect, unless the Column has isTraversalGroup set on it as well.

The following example shows how you can use traversalIndex and isTraversalGroup together.

Example: Traverse clock face

A clock face is a common scenario where standard traversal ordering does not work. The example in this section is based on a time picker, where a user can traverse through the numbers on a clock face and select digits for the hour and minute slots.

A clock face with a time picker above it.
Figure 2. An image of a clock face.

In the following simplified snippet, there is a CircularLayout in which 12 numbers are drawn, starting with 12 and moving clockwise around the circle:

@Composable
fun ClockFaceDemo() {
    CircularLayout {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier) {
        Text((if (value == 0) 12 else value).toString())
    }
}

Because the clock face is not read logically with the default left-to-right and top-to-bottom ordering, TalkBack reads the numbers out of order. To rectify this, use the incrementing counter value, as shown in the following snippet:

@Composable
fun ClockFaceDemo() {
    CircularLayout(Modifier.semantics { isTraversalGroup = true }) {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier.semantics { this.traversalIndex = value.toFloat() }) {
        Text((if (value == 0) 12 else value).toString())
    }
}

To properly set the traversal ordering, first make the CircularLayout a traversal group and set isTraversalGroup = true. Then, as each clock text is drawn onto the layout, set its corresponding traversalIndex to the counter value.

Because the counter value continually increases, each clock value's traversalIndex is larger as numbers are added to the screen— the clock value 0 has a traversalIndex of 0, the clock value 1 has a traversalIndex of 1, and so on. In this way, the order that TalkBack will read them in is set. Now, the numbers inside the CircularLayout are read in the expected order.

Because the traversalIndexes that have been set are only relative to other indices within the same grouping, the rest of the screen ordering has been preserved. In other words, the semantic changes shown in the code snippet above only modify the ordering within the clock face that has isTraversalGroup = true set.

Note that, without setting CircularLayout's semantics to isTraversalGroup = true, the traversalIndex changes still apply. However, without the CircularLayout to bind them, the twelve digits of the clock face are read last, after all other elements on the screen have been visited. This occurs because all other elements have a default traversalIndex of 0f, and the clock text elements are read after all other 0f elements.

Example: Customize traversal order for floating action button

In this example, you use traversalIndex and isTraversalGroup to control the traversal ordering of a Material Design floating action button (FAB). This example is based on the following layout:

A layout with a top app bar, sample text, a floating action button, and
  a bottom app bar.
Figure 3. Layout with a top app bar, sample text, a floating action button, and a bottom app bar.

By default, the layout above has the following TalkBack order:

Top App Bar → Sample texts 0 through 6 → floating action button (FAB) → Bottom App Bar

You may want the screen reader to first focus on the FAB. To set a traversalIndex on a Material element like a FAB, do the following:

@Composable
fun FloatingBox() {
    Box(modifier = Modifier.semantics { isTraversalGroup = true; traversalIndex = -1f }) {
        FloatingActionButton(onClick = {}) {
            Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
        }
    }
}

In this snippet, creating a box with isTraversalGroup set to true and setting a traversalIndex on the same box (-1f is lower than the default value of 0f) means that the floating box comes before all other elements on-screen.

Next, you can put the floating box and other elements into a scaffold, which implements a simple Material Design layout:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnWithFABFirstDemo() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Top App Bar") }) },
        floatingActionButtonPosition = FabPosition.End,
        floatingActionButton = { FloatingBox() },
        content = { padding -> ContentColumn(padding = padding) },
        bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
    )
}

TalkBack interacts with the elements in the following order:

FAB → Top App Bar → Sample texts 0 through 6 → Bottom App Bar

Learn more

To learn more about supporting accessibility in your Compose code, take the Accessibility in Jetpack Compose codelab.