Accessibility basics
Turn on TalkBack and try to play your own Dice Duel. You will not be able to. This lesson audits the real accessibility of all three apps — the labels that are right, the ones that are wrong, and the die that says nothing at all.
Play Dice Duel without looking
Your phone has a built in. It is called , it has been sitting in Settings the whole time, and it is how a large number of people use every app on their phone including yours.
Turn it on and open Dice Duel. Here is what it says as you swipe across the screen:
1DICE DUEL
2First to 30 wins the duel
3PLAYER 1
412
5PLAYER 2
67
7double tap to activate
8PLAYER 1 - TAP TO ROLLRead that again. Everything is there except the die. The one thing the entire game is about announces itself as "double tap to activate" — no shape, no number, no hint that it is a die at all. You can tap it, and then you have no idea what you rolled.
That is not a hypothetical. That is the current state of the app you built, and it takes four lines to fix.
Think about radio commentary of a football match.
The commentator is not describing the pitch to themselves. They are describing it to someone who cannot see it, and the whole job is saying the things a picture would have said: who has the ball, what just happened, how much time is left.
A bad commentator says "and… oh! Well. There we are." Technically speech. Useless.
TalkBack is a commentator that can only read out the labels you wrote. Where you wrote nothing, it says the equivalent of "there we are". Every label in this lesson is you writing the commentary in advance.
TalkBack does not read the screen
This is the idea everything else hangs off. TalkBack never looks at your pixels.
Alongside the picture, Compose builds a second, invisible tree describing what each thing is: this is a button, this says "New note", this is switched on, this can be tapped. That tree is called the tree, and it is the only thing a screen reader ever sees.
Most of the time Compose fills it in for you. Text puts its own words in. Button marks itself a button. The gaps appear in exactly two places: icons, which are shapes with no words, and anything you drew by hand on a Canvas, which to Compose is an anonymous rectangle of paint.
Both of those are why the die is silent.
contentDescription: label it, or say so
Every Icon in Compose demands a contentDescription. Not "recommends" — the parameter has no default, and leaving it out will not compile. That is deliberate: it forces you to make a decision instead of forgetting.
There are only two right answers, and null is genuinely one of them.
Write a label when the icon is the only thing carrying the meaning. Pocket Notes' new-note button is a plus sign and nothing else:
1Icon(
2 imageVector = Icons.Default.Add,
3 contentDescription = "New note"
4)Without that string, TalkBack announces a button with no name. The label describes what happens, not what the picture looks like — "New note", never "Plus icon".
Write null when a label would be repetition. The search box already announces itself; the magnifying glass beside it is decoration:
1Icon(
2 imageVector = Icons.Outlined.Search,
3 contentDescription = null
4)null is not laziness. It is an instruction: skip this, it adds nothing. An icon described when it did not need to be is one more stop the reader has to swipe past, every single time.
Pocket Notes' empty state does the same thing — the big pencil symbol is null, because the headline directly underneath it already says "No notes yet".
Where the real code gets it wrong
Now the uncomfortable part. Focus Flow's bottom bar:
1NavigationBarItem(
2 selected = current == dest.route,
3 onClick = { onSelect(dest) },
4 icon = {
5 Icon(
6 imageVector = dest.icon,
7 contentDescription = dest.label,
8 )
9 },
10 label = { Text(dest.label) },
11)The icon is described as "Timer". The visible label right beneath it is also "Timer". NavigationBarItem merges the whole item into a single announcement, so TalkBack reads the word twice: "Timer Timer, tab, 1 of 3."
The fix is contentDescription = null on the icon. The Text label is already doing the job, and it is doing it for sighted users too.
Focus Flow's settings screen has a subtler version. Four stepper buttons, labelled "Less", "More", "Less", "More":
1StepButton(
2 icon = Icons.Filled.Remove,
3 label = "Less",
4 enabled = value - step >= range.first,
5 onClick = { onChange(value - step) },
6)Sighted, that is obvious — the button sits next to the number it changes. Read aloud, one at a time, you get "Less, button" twice on one screen with no way to tell which one shortens your focus time and which one shortens your break. Passing label = "Less focus time" and "Less break time" costs nothing and removes the guess.
The test is simple: cover the screen and read your labels out in order. If you cannot tell two buttons apart, neither can anyone else.
The die: labelling something you drew yourself
DieFace is a Canvas with a clickable on it. Compose knows it is tappable — that is where "double tap to activate" comes from — and knows nothing else, because a Canvas is just paint.
is how you write into that invisible tree by hand.
Those properties are separate imports, and forgetting them is the commonest way this goes wrong:
1import androidx.compose.ui.semantics.Role
2import androidx.compose.ui.semantics.contentDescription
3import androidx.compose.ui.semantics.role
4import androidx.compose.ui.semantics.semanticsTwo more semantics worth knowing
collapses a group into one announcement. Dice Duel's player panel is currently three separate stops — the name, the score, then a progress bar that says nothing:
1Column(
2 modifier = modifier
3 .semantics(mergeDescendants = true) {
4 contentDescription =
5 "$name has $score of $TARGET points"
6 }
7) {
8 // name, score and progress bar unchanged
9}One swipe, one sentence, all the information. Use this whenever a visual group means more than its parts do.
replaces the generic on/off a control announces. Focus Flow's keep-screen-on switch says "on" today, which is true and unhelpful:
1Switch(
2 checked = checked,
3 onCheckedChange = onChange,
4 modifier = Modifier.semantics {
5 stateDescription = if (checked) {
6 "Screen stays awake"
7 } else {
8 "Screen sleeps as usual"
9 }
10 }
11)And on your screen titles lets a TalkBack user jump between sections instead of swiping through every element to find them.
48dp, and where your apps miss it
Material's rule is that anything tappable must be at least 48dp by 48dp. That is roughly nine millimetres, which is roughly the pad of an adult finger. The visible icon can be smaller — a 24dp icon in a 48dp is the normal arrangement — but the area that responds must not be.
IconButton already gives you this. That is the whole reason it exists as a separate composable, and it is why the clear-search button in Pocket Notes needs no size modifier at all:
1IconButton(onClick = { onQueryChange("") }) {
2 Icon(
3 imageVector = Icons.Outlined.Close,
4 contentDescription = "Clear"
5 )
6}Now look at Focus Flow's stepper buttons, which set their own size:
1FilledTonalIconButton(
2 onClick = onClick,
3 enabled = enabled,
4 modifier = Modifier.size(44.dp),
5)44dp. Four short of the guideline, on the four buttons a user taps most often on that screen. They are still hittable — nobody is going to file a bug — but they are under the line, and that line exists because of people with tremors, cold hands, or a bus going over a speed bump. Changing 44.dp to 48.dp is the entire fix.
This is the kind of thing you only ever find by going looking, which is why the is worth installing. It is a free Google app, it runs on the phone you are already holding, and it draws orange boxes over every touch target that is too small and every piece of text whose contrast is too weak. No laptop needed.
sp for text, dp for boxes
Compose will not let you make the classic mistake. Write fontSize = 15.dp and it refuses to compile, because font sizes take a different type entirely. Text is always in , which is multiplied by the the user chose in Settings — up to double.
The mistake you can still make is putting sp text inside a dp box.
Focus Flow's settings screen shows the current minutes like this:
1Text(
2 text = "$value",
3 style = MaterialTheme.typography.titleLarge,
4 textAlign = TextAlign.Center,
5 color = MaterialTheme.colorScheme.primary,
6 modifier = Modifier.width(48.dp),
7)titleLarge is 22sp. At the largest font setting that is 44sp — and it is being asked to fit in a box frozen at 48dp wide. Two digits at 44sp do not fit in 48dp, so at that setting the number wraps or clips.
The habit that avoids this everywhere: constrain, do not fix. Use widthIn(min =) and heightIn(min =) rather than width() and height(), so a box can grow when its contents do. Focus Flow's Start button already gets this right — .widthIn(min = 168.dp) grows happily when the word "Pause" doubles in size.
The fastest way to find every one of these at once: set your phone's font size to the largest option and open your app. Anything clipped, overlapping or cut in half is a box you froze. It takes ninety seconds and finds more than an hour of reading.
Before you start: know how to turn it off. When you first enable TalkBack, Android offers a shortcut — holding both volume keys for three seconds. Say yes to it. With TalkBack on, one tap selects and two taps activate, which is disorientating the first time.
- On your phone open Settings → Accessibility → TalkBack and turn it on. Accept the volume-key shortcut when it offers.
- Open Dice Duel from the home screen (one tap to select the icon, two taps to open).
- Swipe right repeatedly to walk down the screen. Listen to every stop. Count how many of them tell you something useful.
- Find the die. Listen to what it says. This is the moment the lesson is about.
- Hold both volume keys to turn TalkBack off.
- Open Pocket Studio → Projects → Dice Duel, tap Editor, and open
DieFace.kt. - Add the four semantics imports at the top of the file.
- In the
Canvasmodifier chain, insert the.semantics { }block above.clickable, and change.clickable { onRoll() }to.clickable(onClickLabel = "Roll") { onRoll() }. - Tap Build, then Run ▶.
- Turn TalkBack back on and find the die again. It now says "Die showing 5, button, double tap to roll." Roll it and listen to the number change.
- Turn TalkBack off. Then, while you are here: change
Modifier.size(44.dp)to48.dpin Focus Flow'sStepButtonand rebuild that app too.
Icon(imageVector = Icons.Default.Add) and stopped. Compose's Icon has no default for that parameter, on purpose — it is the one place the framework refuses to let you forget.contentDescription = "New note" — or contentDescription = null if the icon is decoration next to text that already says the same thing.semantics { } block and the property itself has not been imported. Inside that block, contentDescription, role and stateDescription are each a separate extension that needs its own import line.import androidx.compose.ui.semantics.contentDescription, plus .role, .stateDescription or .heading for whichever others you used. Importing only semantics is not enough.Role.Button needs the type as well as the property. It lives in the same package as everything else here, but it is a class rather than an extension, so it is imported by name.import androidx.compose.ui.semantics.Role.android:contentDescription somewhere it does not belong — the manifest, or a vector drawable. It is an attribute of old-style View layouts, and none of your apps have any.Icon or in a semantics { } block. The only labels that live in XML are android:label in the manifest.Canvas, a Box with a clickable, or an Image whose description you set to null by mistake..semantics { contentDescription = "..."; role = Role.Button } to the same modifier chain, and give the clickable an onClickLabel so the action has a verb too.- reads the tree, never the pixels. Compose fills most of it in; the gaps are icons and anything drawn on a
Canvas. - Every
Iconneeds a decision. A real label when the icon carries the meaning, when text beside it already does. Both are correct answers; forgetting is not. - Anything you drew yourself needs
Modifier.semantics— a , a , and an on the click. - turns a group into one sensible sentence; replaces a bare "on" with what being on actually means.
- Tappable things are at least 48dp.
IconButtongives you that free; the moment you set your own size, you own the number. - Text is and grows with the . Boxes around text should use
widthIn/heightIn, never a frozenwidth/height. - Next: dark theme — why your two colour schemes are a redesign rather than an inversion, and how to check a contrast ratio without a laptop.