Scaffold and Window Insets in Jetpack Compose

Quick answer: Use Material 3 Scaffold to organize app bars, bottom navigation, snackbars, and a floating action button. Its content lambda receives innerPadding; apply that padding to your screen content so interactive UI stays clear of bars and cutouts. Do not add a second system-bar padding modifier to the same content unless you have a specific, verified reason.

Edge-to-edge is now the normal Android layout model. On Android 15 devices, apps targeting API level 35 or later draw behind system bars by default. That gives your app a more complete canvas, but it also means an unchecked bottom button, list item, or text field can end up beneath system UI.

This guide uses Material 3 Scaffold to establish a predictable screen shell, then shows when its innerPadding is enough and when keyboard or custom-content insets need extra care.

What are window insets?

Window insets describe parts of the window occupied by system UI or a device feature. The most common ones are:

InsetProtects UI from
System barsStatus bar, navigation bar, and caption bar
Display cutoutA camera notch or other physical cutout
System gesturesGesture areas near the edges
IMEThe on-screen keyboard

Insets do not mean every pixel must be padded away from the edge. Backgrounds, imagery, and a top app bar can draw behind a system bar. The important rule is to keep content that must remain visible or tappable clear of the relevant system UI.

The official edge-to-edge setup guide explains that edge-to-edge is enforced by default on Android 15 (API 35) and later when an app targets API 35 or higher. Calling enableEdgeToEdge() also makes the behavior consistent on earlier Android versions.

Enable edge-to-edge once in the activity

Call enableEdgeToEdge() before setting Compose content:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        setContent {
            MyApp()
        }
    }
}

With the default setup, the status bar and gesture navigation bar can be transparent. In three-button navigation, Android may apply a translucent navigation-bar scrim for contrast. Treat this as a design consideration, not a reason to hard-code an opaque bar behind every screen. See the official system bar protection guidance when a screen needs deliberate protection or icon-contrast changes.

What Scaffold provides

Material 3 Scaffold is a layout shell with slots for a top bar, bottom bar, snackbar host, and floating action button. Most importantly for edge-to-edge work, the content lambda receives PaddingValues:

@Composable
fun InboxScreen(
    onNewMessage: () -> Unit,
) {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Inbox") })
        },
        bottomBar = {
            NavigationBar {
                // Navigation items go here.
            }
        },
        floatingActionButton = {
            FloatingActionButton(onClick = onNewMessage) {
                Icon(Icons.Outlined.Edit, contentDescription = "New message")
            }
        },
    ) { innerPadding ->
        InboxContent(innerPadding)
    }
}

Scaffold supplies the padding information; it does not automatically apply it to the content slot. That is your responsibility. Material 3 app bars and navigation components handle their own appropriate insets, while your custom screen content must consume the innerPadding it receives.

Apply innerPadding to a scrolling list

For a LazyColumn, put the scaffold padding in contentPadding, then consume it on the list itself:

@Composable
fun InboxContent(innerPadding: PaddingValues) {
    LazyColumn(
        modifier = Modifier.consumeWindowInsets(innerPadding),
        contentPadding = innerPadding,
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(messages, key = { it.id }) { message ->
            MessageRow(message = message)
        }
    }
}

This is the Material 3 pattern documented in Use Material 3 insets. contentPadding makes the first and last items reachable without placing them under the app bars. consumeWindowInsets(innerPadding) records that the list has handled those insets, so nested content does not apply the same space again.

If the screen is a long feed, pair this pattern with a lazy list rather than a normal Column; see the LazyColumn guide for item keys, content padding, and list structure.

Apply innerPadding to static content

For a non-scrolling screen, apply the padding directly to the root content container:

@Composable
fun EmptyInbox(innerPadding: PaddingValues) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(innerPadding),
        contentAlignment = Alignment.Center,
    ) {
        Text("No messages yet")
    }
}

This is enough for many screens. Avoid reflexively adding systemBarsPadding() or safeDrawingPadding() to the same root: when the screen is already consuming Scaffold’s padding, the result is often a visibly doubled top or bottom gap.

Keep a custom input above the keyboard

Scaffold knows about the screen chrome; it does not automatically move a custom message composer above the on-screen keyboard. Add IME padding only to the piece that must move when the keyboard appears:

@Composable
fun ChatContent(innerPadding: PaddingValues) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(innerPadding),
    ) {
        LazyColumn(
            modifier = Modifier.weight(1f),
            reverseLayout = true,
        ) {
            // Messages go here.
        }

        MessageComposer(
            modifier = Modifier
                .fillMaxWidth()
                .imePadding(),
        )
    }
}

For keyboard insets to resize the window correctly, configure the activity in the manifest:

<activity
    android:name=".MainActivity"
    android:windowSoftInputMode="adjustResize" />

Compose automatically animates layouts that use insets as IME values change. Test this on a real device or emulator: keyboard behavior is where hidden buttons and duplicate bottom padding are easiest to spot.

When to use a direct inset modifier

Use direct inset modifiers when you are not already relying on Scaffold’s innerPadding, or when only one custom element needs a specific protected region.

FloatingActionButton(
    onClick = onAdd,
    modifier = Modifier
        .navigationBarsPadding()
        .padding(16.dp),
) {
    Icon(Icons.Outlined.Add, contentDescription = "Add item")
}

Useful options include statusBarsPadding(), navigationBarsPadding(), imePadding(), and safeDrawingPadding(). Each is a convenience form of windowInsetsPadding(...). The insets UI guide covers padding, size, and ruler approaches for more specialized layouts.

Apply the smallest relevant inset to the smallest relevant element. For example, a full-bleed photo can remain edge-to-edge while only an overlay action uses safeDrawingPadding().

Override Scaffold insets only deliberately

Scaffold has a contentWindowInsets parameter. Its default is ScaffoldDefaults.contentWindowInsets, which is what produces the padding passed to content. Override it only when your layout has an explicit alternative inset strategy.

Typical reasons include a full-screen media experience or a custom container that owns all inset handling. If you pass an empty WindowInsets value, you take full responsibility for protecting every interactive element. Do not use it merely to make an unexpected gap disappear—first check whether the screen is applying both innerPadding and another system-bar modifier.

Common mistakes

Ignoring innerPadding

This is the classic edge-to-edge bug: a bottom list item, button, or text field is partially hidden by navigation UI. Pass the PaddingValues to your root content or lazy list.

Applying the same inset twice

Scaffold content padding plus safeDrawingPadding() on the same root often creates excess space. Start with the scaffold padding alone. Add a direct inset modifier only to a custom element with a distinct need, such as a keyboard-aware composer.

Padding the background instead of the controls

Edge-to-edge does not require blank bands at the top and bottom. Let decorative content draw behind the bars when it improves the design; inset buttons, input fields, and important text instead.

Testing gesture navigation only

Three-button navigation, display cutouts, landscape orientation, large screens, and the IME expose different overlaps. Test each screen with at least gesture navigation and three-button navigation before treating inset work as complete.

A screen-level checklist

  1. Call enableEdgeToEdge() at activity startup.
  2. Put app-level chrome in Material 3 Scaffold slots.
  3. Apply innerPadding to your static root or lazy-list contentPadding.
  4. Add direct inset padding only where a custom element needs it.
  5. Test top, bottom, side, cutout, and keyboard overlap across navigation modes.

The structure of your content still matters. Use a simple Row, Column, or Box for a small screen section, and keep the screen shell in Scaffold. For components that need optional padding or test tags, pass the behavior through a well-designed Modifier.

FAQ

Why is my LazyColumn under the bottom navigation bar?

Usually the list is ignoring innerPadding. Pass the scaffold padding as contentPadding and consume it with Modifier.consumeWindowInsets(innerPadding) on the list.

Should I put systemBarsPadding() on every screen?

No. If the screen uses Material 3 Scaffold and consumes innerPadding, adding broad system-bar padding can duplicate space. Use direct padding for focused custom elements or screens that do not use a scaffold.

Does Scaffold automatically handle the keyboard?

No. It provides screen-chrome padding. Apply imePadding() to a custom bottom input or another element that must remain visible when the IME appears, and use adjustResize in the activity configuration.