Article
Crafting Accessible Components in Compose
August 27, 2025

If you’re an Android engineer, you’ve probably had to work with some of the accessibility features provided to you at some point. A lot of the more common accessibility considerations engineers encounter are things like content description and font or display sizes. You’re probably familiar with things such as TalkBack and Switch Access. Well when you’re working within a design system library or building custom components, there’s a handful of additional accessibility considerations to keep in mind, and that’s what I want to touch on in this article.
Role Property
First and foremost, you may or may not be familiar with the Role
semantic property. This indicates the type of semantic element to something like TalkBack. Most Material compose components you use will already have this property set correctly, so you don’t usually have to change it. However, imagine creating your own button component, your own dropdown menu, or your own carousel of components. This is where properly setting this property will come in handy. You can view the list of semantic roles here in the SemanticsProperties.kt
file. Some examples include, button, check box, tab, and value picker in addition to some of the others mentioned above.
You can set the Role
semantic property directly via a semantics
modifier or through a clickable
modifier. Setting the role
property on a component to something like Radio Button will read out “Radio Button” via TalkBack when focused. This can help differentiate from something like a Checkbox that can semantically appear similar but is very different. As an example, if you wanted to create your own button surface composable for button components, you’d set the role like this.
@Composable
fun ButtonSurface(
modifier: Modifier,
onButtonPressed: () -> Unit,
enabled: Boolean,
buttonContent: @Composable () -> Unit
) {
// Clickable surface to handle button presses
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.clickable(
enabled = enabled,
role = Role.Button,
onClick = onButtonPressed,
),
propagateMinConstraints = true,
) {
buttonContent()
}
}
Heading Property
Another useful semantic property you may not be familiar with is the heading
property. The need to add a heading
property is debatable, depending on how you read the WCAG article, which you can read for yourself here . However, I think it’s important to have a heading
property on the titles of dialogs and bottom sheets. Imagine having a bottom sheet with the title of “Addresses”. Setting the heading
property will make TalkBack announce “Addresses, Heading” providing more context to the user. Luckily it’s super easy to add but this is something that at this time is not included in the existing compose and Material components.
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false }
) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
//Heading Text - Could also be put in a top bar
Text(
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 16.dp)
.semantics {
heading() // Marked as heading semantically.
},
text = R.string.example_bottom_heading_text,
style = ExampleThemeLocal.Typography.Heading,
)
MyHorizontalDivider()
//Remaining Content
}
}
Pane Title
Property
Speaking of dialogs and bottom sheets, one semantic property I like to add to my dialogs and bottom sheets is a paneTitle
. The paneTitle
will be announced when the dialog or bottom sheet is opened which helps provide additional context. For example, if you set the paneTitle
to “Dialog” on a dialog, it’ll read “Dialog” when it’s opened. It’s a small addition but super easy to add and again, helps provide more context.
Dialog(
onDismissRequest = { showDialog = false },
) {
Surface(
modifier = Modifier.semantics { paneTitle = "Dialog" },
) {
//Dialog content
}
}
For additional context, you can set the paneTitle
on an Alert Dialog to say “Alert Dialog” or if you have a bottom sheet for something like an address picker, you could set the paneTitle
to say “Address Picker Bottom Sheet”. The more context you can provide to the user when a dialog or bottom sheet opens, the better.
Collection Info and Collection Item Info Properties
If you’re building out a component that consists of a list of components, I suggest adding the collectionInfo
and collectionItemInfo
properties. This could be a component like a group of text/radio buttons, or a list of menu items. You’ll want to put the collectionInfo
property on the container of the elements, such as a LazyColumn
, Row
, LazyVerticalGrid
, etc. You’ll then want to add the collectionItemInfo
property on each individual item in the list. Doing this properly will allow TalkBack to read out something like “1 of 10 in list” on each element in said list. Setting the collectionInfo
property basically just takes a row and column count, priming TalkBack or whatever screen reader for how the items are laid out.
Adding the collectionItemInfo
to the items themselves is just as easy. Instead of the row and column count, you’ll want to add the row and column indicies. If you’re displaying things in a column, the column index will just be 0. If you’re displaying things in a row, such as a carousel, then your row index will just be 0. In my experience, I’ve only ever set the rowSpan
and columnSpan
to 1, even in a grid. However if you have some elements that span multiple rows or columns, you’ll want to make sure this is set appropriately within your components as well. When all is said and done, this will help provide additional context when the component is read via something like TalkBack. This is what it looks like when you combine collectionInfo
and collectionItemInfo
.
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.semantics {
collectionInfo = CollectionInfo(
rowCount = listOfItems.count(),
columnCount = 1
)
}
) {
itemsIndexed(items = listOfItems) { index, item ->
Item(
modifier = Modifier.semantics {
collectionItemInfo = CollectionItemInfo(
rowIndex = index,
rowSpan = 1,
columnIndex = 0,
columnSpan = 0
)
},
itemInfo = item,
)
}
You can also make some Modifier
extension methods to help set the collectionInfo
and collectionItemInfo
on either rows or columns. For example, if you wanted to create extension methods for a column, you could do something like what’s shown below. This also easily translates to extension methods for a row as well.
public fun Modifier.columnCollectionInfo(
count: Int
): Modifier = this.semantics {
collectionInfo = CollectionInfo(
rowCount = count,
columnCount = 1
)
}
public fun Modifier.columnCollectionItemInfo(
index: Int
): Modifier = this.semantics {
collectionItemInfo = CollectionItemInfo(
rowIndex = index,
rowSpan = 1,
columnIndex = 0,
columnSpan = 1
)
}
Selected Property
Another semantic property that comes in handy when creating a list of components is the selected
property. If you create a radio button group, checkbox group, or a list of menu items that are selectable rather than actionable, setting the selected
property to true on the selected item will make it read as “selected” when read aloud with TalkBack.
@Composable
fun MenuItem(
text: String,
enabled: Boolean,
selected: Boolean,
onItemPressed: () -> Unit,
) {
Row(
modifier = Modifier.semantics {
this.selected = selected
}
) {
// Menu Item content
}
}
Disabled Property
Along those same lines, the disabled
property should be used in the case where your component, or any part of your component is disabled. Taking the example from above again where you have a list of menu items. If an item is disabled, make sure to set the disabled
semantics property to true on that item. This will make TalkBack read “Disabled” when reading that component, further adding to the context.
@Composable
fun MenuItem(
text: String,
enabled: Boolean,
selected: Boolean,
onItemPressed: () -> Unit,
) {
Row(
modifier = Modifier.semantics {
this.selected = selected
if(!enabled) {
disabled()
}
}
) {
// Menu Item content
}
}
Clickable Modifier onClickLabel
One last accessibility consideration is less of a semantics property and more of an addition to the clickable
modifier. The onClickLabel
is something that can be passed into a clickable
modifier to change what TalkBack says as an option to press a button. By default TalkBack will read “Double tap to activate.” For example, If you had a button to open the details of an order, you could pass in “view order details” and then TalkBack would read “Double tap to view order details.” This can be less useful in something like a design system, but adding this as an option in your components can enable developers to add more detail and context. An example of this could look as follows.
Row(
modifier = modifier
.clickable(
onClickLabel = "view order details",
onClick = onOrderPressed,
),
) {
// Clickable content
}
If you’re working in a design system or crafting reusable components, there’s always a balance to strike between keeping components flexible and providing accessibility features. If you look at the stock Android components, most of them only include composable callbacks for content and leave it to the end user to implement not only the content but also any sort of accessibility. When you start locking down some designs of a component, this is when you can provide these accessibility features to your end users. For the sake of keeping things flexible, however, you may run into cases where you have to leave certain content open ended and provide adequate documentation and guidance to your end user on how to apply the appropriate accessibility features.
We talked a lot about accessibility for custom components and this definitely isn’t a definitive list of properties, however I believe this is a good list of things to keep in mind to take your components to the next level. Appropriately providing these properties in your components ensures that both developers using your components and the end users of the product benefit from the most accessible experience possible.