Bottom navigation in Compose in Modern Android & Kotlin
Bottom navigation is one of the most common UI patterns in mobile apps—it lets users quickly jump between top-level destinations without losing context. In Jetpack Compose, implementing bottom navigation is straightforward, but getting state management, selection highlighting, and tablet support right requires careful consideration. This guide walks through building a production-grade bottom navigation system that scales from phones to tablets.
NavigationBar with items
Compose provides the NavigationBar composable, which replaces the older BottomNavigationView. It's lightweight, composable-friendly, and integrates seamlessly with your navigation graph. The pattern involves defining your top-level destinations, creating navigation items, and wiring them to your navigation controller.
Here's a practical setup. First, define your destinations as sealed classes or objects:
sealed class TopLevelDestination(
val route: String,
val label: String,
val icon: ImageVector
) {
object Home : TopLevelDestination(
route = "home",
label = "Home",
icon = Icons.Default.Home
)
object Orders : TopLevelDestination(
route = "orders",
label = "Orders",
icon = Icons.Default.ShoppingCart
)
object Account : TopLevelDestination(
route = "account",
label = "Account",
icon = Icons.Default.AccountCircle
)
}
val topLevelDestinations = listOf(
TopLevelDestination.Home,
TopLevelDestination.Orders,
TopLevelDestination.Account
)Now create your navigation bar composable. This should be a pure composable that receives the current route and a callback for navigation:
@Composable
fun AppNavigationBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier
) {
NavigationBar(modifier = modifier) {
topLevelDestinations.forEach { destination ->
NavigationBarItem(
selected = currentRoute == destination.route,
onClick = { onNavigate(destination.route) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) }
)
}
}
}In your main screen scaffold, place the navigation bar at the bottom:
@Composable
fun AppScaffold(navController: NavHostController) {
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route
Scaffold(
bottomBar = {
AppNavigationBar(
currentRoute = currentRoute,
onNavigate = { route ->
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
lazyRestoreState = true
restoreState = true
}
}
)
}
) { contentPadding ->
NavHost(
navController = navController,
startDestination = TopLevelDestination.Home.route,
modifier = Modifier.padding(contentPadding)
) {
composable(TopLevelDestination.Home.route) { HomeScreen() }
composable(TopLevelDestination.Orders.route) { OrdersScreen() }
composable(TopLevelDestination.Account.route) { AccountScreen() }
}
}
}Notice the navigation flags: popUpTo ensures the back stack stays clean, while saveState and lazyRestoreState preserve scroll positions and form inputs across tab switches.
Save and restore state between tabs
One of the trickiest parts of bottom navigation is maintaining state when users switch between tabs. Without proper configuration, scrolled lists jump back to the top, and form inputs get cleared. Compose and the Navigation library handle this with the flags we used above, but there's more you can do.
When you set saveState = true, the navigation library keeps the back stack state for that destination in memory. When you return to it later with lazyRestoreState = true, the UI rebuilds with the preserved state. However, this only works if your screen composables remember their own state using rememberSaveable.
@Composable
fun HomeScreen() {
val items by rememberSaveable { mutableStateOf(loadHomeItems()) }
val scrollState = rememberLazyListState()
LazyColumn(state = scrollState) {
items(items.size) { index ->
ItemCard(items[index])
}
}
}For more complex scenarios—like preserving view model state across tab switches—use a scoped view model at the graph level rather than the screen level:
@Composable
fun AppNavHost(navController: NavHostController) {
val sharedViewModel: SharedViewModel = hiltViewModel()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(viewModel = sharedViewModel)
}
composable("orders") {
OrdersScreen(viewModel = sharedViewModel)
}
}
}The view model stays alive as long as the nav graph is alive, so data persists across tab switches without manual state management.
Highlight the current destination
Your navigation bar should visually indicate which tab is active. The NavigationBarItem composable handles this with the selected parameter, but you need to track the current route correctly.
The safest approach is to track the top-level route, not nested routes. If you have a navigation graph like home/details/123, you still want the Home tab highlighted, not a non-existent "details" tab:
@Composable
fun AppNavigationBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier
) {
NavigationBar(modifier = modifier) {
topLevelDestinations.forEach { destination ->
NavigationBarItem(
selected = currentRoute?.startsWith(destination.route) == true,
onClick = { onNavigate(destination.route) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) },
alwaysShowLabel = true
)
}
}
}Use startsWith to match the top-level route even if nested routes exist. Alternatively, extract just the first segment:
fun String?.getTopLevelDestination(): String? {
return this?.split("/")?.firstOrNull()
}Then compare:
selected = currentRoute.getTopLevelDestination() == destination.routeThis ensures highlighting works correctly even with deep links or nested navigation.
Use NavigationRail on tablets
Phones and tablets have different affordances. On tablets with landscape orientation or larger screens, a bottom navigation bar wastes space. Jetpack Compose provides NavigationRail for vertical navigation on the side of the screen.
Detect screen size and choose the appropriate navigation component:
enum class WindowSizeClass {
Compact, Medium, Expanded
}
@Composable
fun calculateWindowSizeClass(): WindowSizeClass {
val configuration = LocalConfiguration.current
return when {
configuration.screenWidthDp < 600 -> WindowSizeClass.Compact
configuration.screenWidthDp < 840 -> WindowSizeClass.Medium
else -> WindowSizeClass.Expanded
}
}Use this to switch between NavigationBar and NavigationRail:
@Composable
fun AppScaffold(navController: NavHostController) {
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route
val windowSizeClass = calculateWindowSizeClass()
val navigationContent: @Composable () -> Unit = {
when (windowSizeClass) {
WindowSizeClass.Compact -> {
AppNavigationBar(
currentRoute = currentRoute,
onNavigate = { route -> navigateToTopLevel(navController, route) }
)
}
else -> {
AppNavigationRail(
currentRoute = currentRoute,
onNavigate = { route -> navigateToTopLevel(navController, route) }
)
}
}
}
Scaffold(
bottomBar = if (windowSizeClass == WindowSizeClass.Compact) navigationContent else ({})
) { contentPadding ->
Row {
if (windowSizeClass != WindowSizeClass.Compact) {
navigationContent()
}
NavHost(navController = navController, startDestination = "home")
}
}
}Create the NavigationRail version:
@Composable
fun AppNavigationRail(
currentRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier
) {
NavigationRail(modifier = modifier) {
topLevelDestinations.forEach { destination ->
NavigationRailItem(
selected = currentRoute?.startsWith(destination.route) == true,
onClick = { onNavigate(destination.route) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) }
)
}
}
}This gives you adaptive navigation that respects the device's form factor and orientation.
Common mistakes
- Forgetting navigation flags: Omitting
saveState = trueandlazyRestoreState = truecauses state loss and jarring UX when switching tabs. - Comparing full routes instead of top-level routes: If your route is
home/details/42but you compare againsthomeusing exact equality, nothing highlights. UsestartsWithinstead. - Creating new instances of view models per screen: Always scope shared state to the navigation graph, not individual screens, or you'll lose data on tab switches.
- Ignoring tablet layouts: Deploying an app with bottom navigation on a 10-inch tablet looks unprofessional. Always provide adaptive navigation using window size classes.
- Not using rememberSaveable: Local screen state (like scroll position) won't survive process death unless you explicitly mark it with
rememberSaveable.
Key takeaways
- Build bottom navigation with
NavigationBarandNavigationBarItem, connecting items to your destination routes via a navigation controller callback. - Preserve state across tab switches by setting
saveState = true,lazyRestoreState = true, and usingrememberSaveablein your screens. - Highlight the current destination by matching the top-level portion of the route using
startsWithrather than exact equality. - Adapt your navigation to device form factor: use
NavigationBaron phones andNavigationRailon tablets by detecting window size class. - Scope shared view models to the navigation graph level, not individual screens, to preserve data across tab navigation.






Comments
Loading comments…