Reading order is layout order, until your layout stops matching your meaning
Screen readers traverse in layout order, which breaks when a floating action button, a sticky header or a two-column layout puts visual priority somewhere else. Traversal groups and indices reorder without moving anything.

Day 69 of 100. Two days of getting the content of the semantics tree right; today, the order it's read in.
The symptom
A screen where the most important control is announced last:
Scaffold(
floatingActionButton = { FloatingActionButton(onClick = ::compose) { … } },
) { padding ->
LazyColumn(contentPadding = padding) {
items(messages, key = { it.id }) { MessageRow(it) }
}
}
The FAB is the screen's primary action — Day 36's rule — and it's drawn last, so a screen-reader user reaches it after swiping through every message in the list. On an inbox with two hundred messages, "compose" is effectively unreachable by sequential navigation.
The same shape appears with a sticky footer, a bottom bar, or anything positioned by overlay rather than by document order.
Why the obvious fix fails
The obvious fix is to move the FAB earlier in the code:
Box {
FloatingActionButton(…) // first in source
LazyColumn { … }
}
Now the FAB is announced first and drawn behind the list, because a Box stacks in
declaration order. Fixing the reading order broke the visual one.
The two orders are genuinely independent requirements, and any solution that ties them together will fail one of them.

The actual mechanism
Two properties control traversal, and they work together.
isTraversalGroup = true marks a subtree as a unit. Everything inside it is traversed
before moving on, rather than interleaving with siblings by position.
traversalIndex orders siblings within a group. It's a Float, defaults to 0f, and
lower comes first — so negative values move things earlier.
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = ::compose,
modifier = Modifier.semantics { traversalIndex = -1f }, // before the list
) { Icon(Icons.Default.Edit, "Compose") }
},
) { padding ->
LazyColumn(
modifier = Modifier.semantics { isTraversalGroup = true },
contentPadding = padding,
) {
items(messages, key = { it.id }) { MessageRow(it) }
}
}
The FAB is now announced early and still drawn on top. Nothing moved.
The Float type matters: it lets you insert between existing values without renumbering.
-0.5f sits between -1f and 0f, which is the same reason fractional ordering keys
appear in databases and drag-and-drop lists.
Traversal groups are the more important half
traversalIndex gets the attention; isTraversalGroup fixes more real bugs.
Without grouping, traversal is roughly top-to-bottom, left-to-right across the whole screen. In a two-column layout that produces interleaving:
Row {
Column { Text("Name"); Text("Ada Lovelace") } // left column
Column { Text("Role"); Text("Engineer") } // right column
}
Read positionally, that's "Name, Role, Ada Lovelace, Engineer" — the two columns interleaved row by row, which is nonsense. Marking each column as a traversal group gives "Name, Ada Lovelace" then "Role, Engineer".
The rule worth applying: any container whose children should be read together is a traversal group. Cards, columns in a multi-column layout, sections of a form, each pane of Day 31's list-detail scaffold.
Where it matters most
Four screens that almost always need this:
A screen with a FAB. As above.
A sticky or floating header. Drawn over content, so it's traversed after it. A search bar overlaying a list is announced after the list.
Two-pane adaptive layouts. Day 31's scaffold — without grouping, the list and detail panes interleave. Each pane should be a group.
Bottom sheets and dialogs. These usually get correct behaviour automatically because they're separate windows, but a non-modal bottom sheet drawn inside the same window needs grouping, or its content interleaves with the screen behind it.
The related focus question
Keyboard focus order — Day 34's desktop concern — is a separate mechanism:
Modifier.focusProperties {
next = nextFocusRequester
previous = previousFocusRequester
}
They're different systems with different defaults, and both matter on a device with a keyboard. The practical advice is to fix traversal first, since it's the one affecting the larger audience, then tab through the screen to check focus separately.
The one thing they share: both are invisible until someone navigates without touching the screen, and both are checked in about a minute.
Headings give you the other navigation mode
Sequential swiping isn't the only way through a screen, and the alternative is one property:
Text(
"Recent",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
TalkBack can jump heading-to-heading, which turns a long settings screen from fifty swipes into six. Marking section titles as headings is the highest-value-per-keystroke thing in this entire pillar, and it takes one modifier per section.
The rule mirrors HTML: if it looks like a section title, it is a heading. A screen with visual section titles and no headings is one where the visual structure exists for sighted users only.
Don't over-order
A caution, because traversalIndex invites tinkering. Layout order is right most of the
time, and an app with indices scattered through it becomes hard to reason about — the
reading order is no longer inferable from the source.
Reach for it when there's a specific mismatch between visual priority and layout position. If you find yourself ordering an entire screen by hand, the layout probably doesn't match the information hierarchy, and fixing that helps everyone rather than only screen-reader users.
How to prove it
The direct test is sequential navigation. With TalkBack on, swipe right repeatedly from the top of the screen and write down the order. Compare it to the order you'd read the screen aloud to someone over the phone — those should match.
For automation, the merged tree's order is assertable:
@Test fun composeButtonIsReachedEarly() = runComposeUiTest {
setContent { InboxScreen(manyMessages) }
val order = onRoot().fetchSemanticsNode().children.map { it.config.getOrNull(ContentDescription) }
assertTrue(order.indexOf(listOf("Compose")) < 3)
}
Blunt, and it catches the regression where someone adds a header and pushes the primary action back down the list.
The cheapest signal of all: count the swipes to reach the screen's main action. If it's more than three or four on any screen, the order needs work. On a list screen that number should be independent of how many items the list holds — if it grows with the data, something is being traversed that shouldn't be.
What this generalizes to
The principle is visual order and semantic order are different requirements, and a layout system that conflates them will get one of them wrong. Position is a good default for reading order, and it's only a default — overlays, floating elements and multi-column layouts all break the correspondence.
HTML has the identical tension: the DOM order determines reading order, CSS can reposition
freely, and tabindex is the escape hatch with the same warning attached — use it for
specific mismatches, not to hand-order a page. The warning is the same because the
failure mode is: a hand-ordered page is correct on the day it ships and drifts the moment
someone inserts an element without updating the indices. Two ecosystems, one lesson: separate the
orders, then keep them in sync deliberately.
Tomorrow, Day 70 closes the pillar with testing accessibility, and how much of it is automatable.
Day 69 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Traversal order.