There are three kinds of inset, and using the wrong one is why your keyboard fights your layout
Compose exposes system insets as composable values with padding, size and consume modifiers. Knowing the three safe-inset groups and how the keyboard inset animates resolves most edge-to-edge layout problems.

Day 96 of 100. Yesterday said "consume the insets". Today: which ones, with what, and the keyboard case that behaves differently from all of them.
The symptom
A chat screen where the keyboard covers the input:
Column(Modifier.fillMaxSize()) {
MessageList(Modifier.weight(1f))
MessageInput(Modifier.fillMaxWidth())
}
Tap the field and the keyboard slides up over it. The user is typing into something they
can't see. Adding android:windowSoftInputMode="adjustResize" to the manifest helps in a
View app and does nothing useful here, because Compose handles the keyboard as an inset
rather than by resizing the window.
Why the obvious fix fails
The obvious fix is to pad by the keyboard height:
val ime = WindowInsets.ime.getBottom(LocalDensity.current)
Column(Modifier.padding(bottom = with(LocalDensity.current) { ime.toDp() })) { … }
This works and janks. The keyboard animates in over about 250ms, and reading its height in composition means recomposing the whole column on every frame of that animation — Day 82's problem, in a place where it's very visible.
There's a modifier that does the same thing in the right phase.

The actual mechanism
An inset is a set of four measurements — how much the system occupies on each edge.
Compose exposes them as WindowInsets values readable in composition.
The individual ones:
WindowInsets.statusBars WindowInsets.navigationBars
WindowInsets.displayCutout WindowInsets.ime
WindowInsets.systemGestures WindowInsets.captionBar
And three groups, which are what you should usually reach for:
safeDrawing — everywhere the system might draw over you. Status bar, navigation bar,
cutout, and the keyboard. The default choice for content that must be visible.
safeGestures — where the system reserves gestures: the back-gesture edges, the
home-swipe area. The choice for anything draggable, so your slider doesn't sit where a
back swipe starts.
safeContent — the union of both. For content that must be visible and interactive.
The rule: safeDrawing for what must be seen, safeGestures for what must be touched,
safeContent when both.
Three things to do with one
Each inset supports three modifiers, and the difference matters:
Modifier.windowInsetsPadding(WindowInsets.safeDrawing) // pad by it
Modifier.windowInsetsTopHeight(WindowInsets.statusBars) // BE its size — a spacer
Modifier.consumeWindowInsets(paddingValues) // tell children it's handled
Padding is the common one, with shorthands: safeDrawingPadding(),
statusBarsPadding(), navigationBarsPadding(), imePadding().
Size is for a spacer that occupies exactly the bar's area — how you draw a coloured block behind the status bar rather than letting content show through.
Consume is the one that fixes double padding. When a parent has already padded for an inset, children shouldn't pad again:
Scaffold { innerPadding ->
Box(Modifier.consumeWindowInsets(innerPadding)) {
// children asking for safeDrawing get zero — already handled above
InnerContent()
}
}
Without it, a nested component calling navigationBarsPadding() adds the bar's height a
second time. This is the most common edge-to-edge bug after "no padding at all", and it's
harder to spot because it looks like a spacing mistake.
The keyboard
WindowInsets.ime is different from the others in two ways: it animates, and it changes
frequently.
The right tool is the modifier, not a read:
Column(Modifier.fillMaxSize().imePadding()) {
MessageList(Modifier.weight(1f))
MessageInput(Modifier.fillMaxWidth())
}
imePadding() applies the inset in the layout phase, so the keyboard animation costs
layout and draw rather than a recomposition per frame — Day 82's deferred read, provided as
a modifier.
Two companions worth knowing:
Modifier.imeNestedScroll() on a scrollable makes the keyboard dismiss as you scroll
away from the field, which is the behaviour messaging apps have.
WindowInsets.isImeVisible for when you genuinely need the boolean — showing a "send"
button only while typing, say. It's a state read, so use it for structure rather than for
positioning.
There's a third, less-known option for the case where you want your content to track the
keyboard frame by frame rather than settling with it: Modifier.imeNestedScroll() plus
the animated variant of the inset. Most screens don't need it — imePadding() already
animates in step — but a custom sheet that should follow the keyboard exactly does.
The manifest still matters: android:windowSoftInputMode="adjustResize" is required for
the ime inset to be reported at all on some versions. It's the one piece of XML an
otherwise-Compose app still needs.
Nested scaffolds and the reason they double
Day 88 mentioned this; here's the mechanism. Each Scaffold consumes the insets available
to it and reports the remainder. A nested one sees the original insets again unless the
outer one's padding was consumed, so it pads for the status bar a second time.
The fix is either one scaffold per screen — the recommendation — or
consumeWindowInsets between them.
The same applies across the interop boundary: a ComposeView inside a layout with
fitsSystemWindows="true" gets insets that the View system has already consumed, or hasn't,
depending on configuration. A screen with visible double padding after a migration is
almost always this.
Drawing behind, deliberately
The look edge-to-edge exists for:
Box {
AsyncImage(
model = hero,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(280.dp), // NO inset padding — behind the bar
)
TopAppBar(
title = { Text("Order") },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier = Modifier.statusBarsPadding(), // only the CONTENT is padded
)
}
The image extends under the status bar; the title clears it. That split — background ignores the inset, content respects it — is the whole visual idiom, and it's why the modifiers apply per-element rather than per-screen.
How to prove it
Yesterday's two checks plus one:
Open a screen with a text field, tap it, and watch the transition. The content should rise
with the keyboard rather than jumping when it finishes. A jump means the inset is being
read in composition rather than applied with imePadding().
For double padding, the tell is a gap that's suspiciously exactly a bar's height. Comment out one consumer and see whether the layout becomes correct — if it does, two things were padding for the same inset.
A useful debugging aid while hunting one:
Modifier.drawBehind {
drawRect(Color.Red.copy(alpha = 0.2f)) // on the element you suspect
}
Tinting the padded container makes it obvious whether the gap is inside or outside it, which distinguishes "this element padded twice" from "its parent already padded".
What this generalizes to
The principle is the environment is measurable, and the measurement is per-element. Insets aren't a screen-level setting; they're values that different parts of a layout answer differently — a background ignores them, content respects them, a draggable control respects a different set entirely.
That's why the API is a family of modifiers rather than a flag. fitsSystemWindows was a
boolean, and a boolean can't express "the image goes behind and the title doesn't", which
is exactly what every modern screen wants.
The general shape: when a system-level concern needs different answers in different parts of a tree, it has to be expressible per element. A screen-level setting can only be right for the majority of a screen, and the exceptions are where the design lives.
Tomorrow, Day 97: predictive back — the gesture that shows the user where they're going before they commit.
Day 96 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Window insets.