Bar, rail, drawer — the same destinations, chosen by window width
NavigationBar, NavigationRail and the drawer are three presentations of the same destination list. Window size decides which, and NavigationSuiteScaffold does the switching — with rules about how many destinations belong there at all.

Day 40 of 100. Top-level navigation is where the adaptive pillar and the components pillar meet, and there's a component that does the meeting for you.
The symptom
A bottom bar that's still a bottom bar on a tablet:
Scaffold(
bottomBar = {
NavigationBar {
destinations.forEach { dest ->
NavigationBarItem(
selected = current == dest,
onClick = { navigate(dest) },
icon = { Icon(dest.icon, null) },
label = { Text(dest.label) },
)
}
}
},
) { … }
On a 1000dp window, five items sit in a row across the bottom of a very wide screen, each one an inch from its neighbour, while the left third of the display is empty. It works and it wastes the space the adaptive work was meant to use.
Why the obvious fix fails
The obvious fix is a when on the size class:
when (widthClass) {
COMPACT -> Scaffold(bottomBar = { NavigationBar { items() } }) { Content(it) }
MEDIUM -> Row { NavigationRail { items() }; Content() }
EXPANDED -> PermanentNavigationDrawer(drawerContent = { items() }) { Content() }
}
It's correct, and now the destination list is written three times with three different
item components — NavigationBarItem, NavigationRailItem, NavigationDrawerItem.
Adding a sixth destination means three edits, and the three copies drift.
There's also a subtler cost: switching between the branches remounts Content(), because
it's a different position in the composition tree. Scroll position resets when you unfold
the device.

The actual mechanism
NavigationSuiteScaffold takes the destination list once and picks the presentation:
NavigationSuiteScaffold(
navigationSuiteItems = {
destinations.forEach { dest ->
item(
selected = current == dest,
onClick = { navigate(dest) },
icon = { Icon(dest.icon, contentDescription = null) },
label = { Text(dest.label) },
)
}
},
) {
Content()
}
One list, one item call per destination. The scaffold reads
currentWindowAdaptiveInfo() — Day 29, again — and renders a bottom bar on compact, a
rail on medium, and a permanent drawer on expanded. Content() stays in the same place
in the tree, so unfolding doesn't reset it.
Overriding the choice is a parameter rather than a rewrite:
NavigationSuiteScaffold(
layoutType = if (immersiveMode) NavigationSuiteType.None
else NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(
currentWindowAdaptiveInfo()
),
…
)
NavigationSuiteType.None hides navigation entirely, which is what a video player or a
camera screen wants.
How many destinations
The component won't stop you, so the constraint is worth stating: three to five top-level destinations. Below three, a bar is unnecessary — use a single screen with tabs or a menu. Above five, items get too narrow to label and users stop reading them.
If you have seven, the honest answers are to group some behind a "More" destination, or to accept that some of them aren't top-level. A navigation bar is a claim about what the app is for, and seven equally-weighted claims is none.
The rail relaxes this slightly — vertical space is cheaper than horizontal, so seven rail items are legible where seven bar items are not. That is a reason to hide some destinations on compact, not a reason to add more overall.
Labels matter too. alwaysShowLabel = false on NavigationBarItem hides labels for
unselected items, which looks cleaner and measurably hurts discoverability — an icon
alone is ambiguous to anyone who hasn't already learned it.
Wiring it to the nav host
The navigation logic itself is separate, and there's one detail that produces a bug in every app that misses it:
fun navigateToTopLevel(route: String) {
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
Without launchSingleTop, tapping the same destination twice pushes it twice, so back
goes to the same screen again. Without saveState/restoreState, switching tabs and
returning loses the scroll position and any in-progress input on the first tab.
Those three options are the difference between navigation that feels native and navigation that feels like a web page from 2005, and they're easy to omit because the app works without them.
The modal drawer is a different thing
ModalNavigationDrawer — the one that slides over the content — is for secondary
navigation, not top-level. Settings, help, account switching, archived sections.
val drawerState = rememberDrawerState(DrawerValue.Closed)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = { ModalDrawerSheet { SecondaryDestinations() } },
) {
NavigationSuiteScaffold(…) { Content() }
}
Both can coexist — a bar for the three things people do daily, a drawer for the twelve they do occasionally. Putting the daily destinations in a drawer hides them behind a gesture, which is why the pattern fell out of favour for primary navigation.
How to prove it
The three presentations are one preview annotation each:
@Preview(widthDp = 400) @Preview(widthDp = 700) @Preview(widthDp = 1100)
@Composable fun NavAcrossWidths() = AppShell()
Bar, rail, drawer — with a single implementation. If you see the same bottom bar three times, the layout type isn't being calculated.
The state-preservation bug needs a device: pick tab two, scroll down, go to tab one, come
back. If you're at the top of tab two, saveState/restoreState are missing. Then
unfold: if the scroll resets, Content() is being remounted by a when.
Badges
Unread counts belong on the item, and there's a slot for them rather than a hand-placed overlay:
item(
icon = {
BadgedBox(
badge = {
if (unread > 0) Badge { Text(if (unread > 99) "99+" else "$unread") }
},
) { Icon(dest.icon, contentDescription = null) }
},
…
)
Two details that are easy to get wrong. Cap the number — a four-digit badge stretches the
item and breaks the row's spacing. And an unlabelled Badge { } with no content is the
correct choice for "something is new" without a count, rather than a badge showing zero.
For accessibility, the count needs to reach the semantics tree, since a screen reader won't infer it from the drawing:
Modifier.semantics { contentDescription = "$unread unread messages" }
Without that, the item announces "Inbox" and a sighted user's most important cue is invisible to everyone else.
What this generalizes to
The pattern is separating what from how. The destination list is app content; the choice of bar, rail or drawer is presentation, and it depends on a window property that changes at runtime. Writing them together means writing the content three times.
It also explains why three item types exist despite looking interchangeable. They differ
in layout — horizontal for a bar, vertical for a rail, full-width for a drawer — and the
scaffold picks the right one from the same item() declaration. You describe the
destination; the component decides what a destination looks like in the presentation it
chose.
That's the same separation as Day 30's canonical layouts, one level up — and it's why
NavigationSuiteScaffold exists rather than better documentation for the when. When a
branch produces duplicated content, the fix is usually a component that takes the content
once.
Tomorrow, Day 41: progress indicators — determinate versus indeterminate, and why the spinner you show for 200ms is worse than no spinner.
Day 40 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Navigation components.