Anatomy of the System Bars in Jetpack Compose: Status Bar and Navigation Bar with enableEdgeToEdge
How the status bar and navigation bar work on Android, what edge-to-edge changed, and how to configure them in Jetpack Compose with enableEdgeToEdge and insets.

I have had App Store submissions rejected because of the status bar. The problem was contrast: the color of the system icons did not match the screen background, and the information at the top was hard to read. On Android, the same slip usually shows up at the bottom, with a white back icon on a white navigation bar, and the user cannot find a way out of the screen.
This kind of mistake does not depend on the framework. In Jetpack Compose, edge-to-edge changed the rule: painting the status bar with the app color stopped working, and the icon color now depends on what the app draws behind it. That is why I decided to write about the anatomy of these bars, how they behave and how to configure them in Compose.
What belongs to the system and what belongs to the app

On Android, the screen has two strips that do not belong to the app. At the top is the status bar, with time, network and battery, sharing space with the camera cutout. At the bottom is the navigation bar, which can be the 3-button one (back, home and recents) or just the gesture bar.
The app does not draw the icons on these bars. What it controls is two things: what shows behind them and whether the system icons will be light or dark. The first answer comes from the app layout. The second is a setting many people never touch, and it decides whether the time and the back button will be readable.
The mistake that usually happens

This mistake reaches the store because it depends on context. Developers usually test with one theme and one navigation mode, most often gestures, where the bottom bar barely shows. The user runs the dark theme, or 3-button navigation, or lands on a screen without a TopAppBar, like the splash, the onboarding or a video player. Those screens inherit the style of the previous screen, which was designed for a different background.
The belief behind the problem is "the bar belongs to the system, so the system takes care of it". Part of it is true: the operating system is what draws the time and the battery. But the color of those icons is the app's decision, and the system does not check whether it matches what sits behind it.
Edge-to-edge changed the rule on Android

For a long time, the fix was to paint the status bar with the app color. Starting with Android 15, every app with targetSdk 35 draws edge to edge by default: content goes behind the status bar and the navigation bar. setStatusBarColor is now ignored, gesture navigation is transparent and 3-button navigation gets a translucent background at 80% opacity. Android 15 still let you opt out of this behavior, but for apps with targetSdk 36, on Android 16, that option is gone.
In practice, two responsibilities are left. The first is choosing the icon color according to the background that will sit behind it. The second is applying the insets, the measures of how much each bar takes up, so the background runs behind them but interactive content does not end up under the clock or the gesture bar.
How each framework names light and dark

Before writing any code, answer one question: is the background behind the bar light or dark? A light background calls for dark icons, and a dark background calls for light icons.
The confusion is that each framework names this differently. In Flutter, the name describes the icon, so dark icons are dark. In Compose and SwiftUI, the name describes the background, so the same dark icons are light.
In Jetpack Compose: enableEdgeToEdge

In Compose, the configuration lives in the Activity. enableEdgeToEdge turns on edge-to-edge also on versions before Android 15 and takes the style of each bar:
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
// light background behind the status bar: dark icons
statusBarStyle = SystemBarStyle.light(
scrim = Color.TRANSPARENT,
darkScrim = Color.BLACK,
),
// navigationBarStyle stays at the default: transparent with gestures,
// translucent with 3-button navigation
)
setContent { MyAppTheme { AppNavHost() } }
}
}The name here is SystemBarStyle.light, and the result is dark icons, the opposite of the Flutter preset with the same name. darkScrim only kicks in on old devices, where the system cannot draw dark icons. If the app follows the system dark theme, switch to SystemBarStyle.auto, which toggles on its own.
For a screen that breaks from the default, the way is to adjust the WindowInsetsControllerCompat while it is in the composition and restore the previous value when it leaves:
@Composable
fun StatusBarIcons(dark: Boolean) {
val view = LocalView.current
if (view.isInEditMode) return
DisposableEffect(dark) {
val window = (view.context as Activity).window
val controller = WindowCompat.getInsetsController(window, view)
val previous = controller.isAppearanceLightStatusBars
controller.isAppearanceLightStatusBars = dark
onDispose { controller.isAppearanceLightStatusBars = previous }
}
}
@Composable
fun PlayerScreen() {
StatusBarIcons(dark = false) // black background, light icons
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.safeDrawingPadding(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier.size(96.dp),
)
}
}onDispose plays the role AnnotatedRegion plays in Flutter: when the player leaves the screen, the icons go back to dark. safeDrawingPadding() is the equivalent of SafeArea.
In lists, the Scaffold hands the insets over ready in innerPadding:
@Composable
fun ProfileScreen(rows: List<String>) {
Scaffold { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
items(rows) { item ->
Text(item, modifier = Modifier.padding(16.dp))
}
}
}
}Passing innerPadding as contentPadding, and not as Modifier.padding, is what lets the list scroll behind the bars without hiding the first and the last item.

Before sending it to the store
Most of these mistakes only show up in a specific combination of theme, navigation and screen. Before each submission, it is worth going through three scenarios:
Light and dark theme. Switch the system theme with the app open and go through every screen.
3-button navigation. Turn it on in the Android settings. That is where the back button disappears when the contrast is wrong.
Screens without a TopAppBar. Splash, onboarding, player, camera and any screen with an image behind the status bar.
The rule that holds for any framework
Every decision about the system bars starts with the color of the background behind them. With that answer, the name of each API becomes a translation detail, and the rest is applying the insets so the content does not end up under the clock or the gestures.
None of these APIs checks your configuration. Android's translucent background protects 3-button navigation, but when the app declares the wrong color, the white icon on a white background reaches the store review all the same.
After the bars, the next step is the other insets edge-to-edge left exposed: the keyboard, the camera cutout when the app rotates to landscape and the fold on foldable devices. In all of them the system takes up part of the screen, and the app needs to know which part.
Keep reading
Enhancing User Experience with Keyboards on iOS using Keyboard Actions
You'll never struggle with the iOS Keyboard in Flutter again! See how to solve a common problem in mobile apps for iOS that is…
Flutter Widgetbook: How to Document your Design System the Right Way!
How Widgetbook solves the documentation problem in Flutter projects with a custom design system. On Direção Fácil, it became a visual catalog, a contract with Figma, and precise context for Claude Code to generate screens using the right widgets.
Creating an AI Agent in Claude Code to Control my Smartphone
Automating Android app documentation and testing with an AI agent in Claude Code, wired to an MCP server that drives a real device over ADB.
This article was translated from Portuguese with the help of an LLM. The original version may contain nuances not fully captured in this translation.