Safe Areas
A phone screen isn't a rectangle. The Dynamic Island or a camera cutout, the status bar, the rounded corners and the home indicator all take space along the edges. Put text there and it gets covered. Stop the background there and the app looks letterboxed.
Morning Mix
13 songs, 48 min
- Slow Morning
- Paper Boats
- Northbound
- Glasshouse
- Late Bloom
- Tidewater
- Small Hours
Try each mode, then show the insets.
Ignored, the title runs into the status bar and the Play button sits on the home indicator, where a swipe up goes home instead. Boxed, everything is readable, but the app stops short of the edges with plain bars above and below. Edge to edge, the background fills the screen and only the content keeps its distance.
Let backgrounds and scrolling content run to the edges of the screen, and keep text and controls inside the safe area.
iPhone
SwiftUI keeps views inside the safe area by default. Two modifiers do the rest:
ignoresSafeArea()on backgrounds only. Put it on the whole screen and your buttons go under the home indicator.safeAreaInset(edge:)for a custom bar. It shrinks the safe area for everything else, so a scroll view's last row can still clear the bar.
Since iOS 26, bars float on Liquid Glass with a scroll edge effect. Apple says to use that instead of a solid color behind controls, so don't paint opaque backgrounds onto system bars.
Android
Android 15 made apps that target API 35 edge-to-edge. The status bar and the gesture navigation bar are transparent, and 3-button navigation gets an 80% opaque scrim. Android 16 removed the opt-out for apps that target API 36. Google Play has required API 36 for new apps and updates since 2026-08-31, so every app you ship is edge-to-edge now.
Call enableEdgeToEdge() for the same behavior on older versions. In
Compose, Material 3's Scaffold pads its bars and passes the remaining
insets to your content. Use WindowInsets.safeDrawing for anything you
draw yourself. Don't wrap the root in systemBarsPadding(), which brings
the letterbox back.
React Native and Expo
Wrap the app in SafeAreaProvider and use SafeAreaView from
react-native-safe-area-context, with only the edges a screen needs. Expo
Router's native header and native tabs already handle their own edges, so
a screen under both often needs no edges at all. The library's
SafeAreaView applies insets natively; the useSafeAreaInsets hook is
for custom layouts and can cause layout flicker in some cases.
The SafeAreaView in React Native core has been deprecated since 0.81. It
only ever worked on iOS. React Native 0.81 targets API 36, and Expo made
edge-to-edge mandatory on Android in SDK 54, so Android has real insets
too. Platform.OS === "android" ? 0 : insets.top and
StatusBar.currentHeight hacks are wrong now.
In Flutter, SafeArea pads all four edges by default. Read
MediaQuery.paddingOf(context) when you need the numbers.
Scroll under the bars
A list should start below the top bar and end above the tab bar, but scroll under both. That needs padding inside the scroll view, not around it.
Library
Scroll the list in each mode and try to reach the last row.
With an overlay, the tab bar is drawn on top and the list doesn't know, so
the last row can never scroll out from under it. With padding on the scroll
view, the list stops dead at the bars: nothing passes under them, and the
space beside and below the floating tab bar sits empty. With
a content inset, the list fills the screen and its content is padded by the
bars and the insets. Compose calls this contentPadding, iOS does it
automatically for system bars, and React Native needs
contentInsetAdjustmentBehavior="automatic" or a paddingBottom in
contentContainerStyle.
Never hard-code an inset
The insets change between models and in landscape, where the camera cutout
moves to one side and the side insets grow. On iPhone Duo, the outer display puts the
tab bar and toolbar in a strip on one side, so the left and right insets
differ. iOS 27 also lets iPhone apps resize freely in iPhone Mirroring and
on iPad. Read the insets from the system every time. A padding(.top, 47)
or 24.dp that fits your phone is wrong on the next one.
Usage
ZStack {
LinearGradient(colors: [.indigo, .black], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea() // the background only
ScrollView {
SongList() // content stays in the safe area
}
.safeAreaInset(edge: .bottom) { // iOS 15+: the list scrolls clear of it
PlayButton()
.padding(.horizontal)
.padding(.bottom, 8)
}
}class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { AppTheme { Library() } }
}
}
@Composable
fun Library() {
Scaffold(
topBar = { TopAppBar(title = { Text("Library") }) },
bottomBar = { NavigationBar { /* items */ } },
) { inner ->
LazyColumn(
contentPadding = inner, // not Modifier.padding(inner)
modifier = Modifier.fillMaxSize().consumeWindowInsets(inner),
) { items(songs, key = { it.id }) { SongRow(it) } }
}
}import { FlashList } from "@shopify/flash-list";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
// Root: <SafeAreaProvider><Stack /></SafeAreaProvider>
// The native header covers the top, so pad only the bottom.
export function PlaylistScreen() {
return (
<SafeAreaView edges={["bottom"]} style={{ flex: 1 }}>
<SongList />
<PlayButton />
</SafeAreaView>
);
}
// A list under your own floating bar: pad the content, not the list.
export function Library() {
const insets = useSafeAreaInsets();
return (
<FlashList
data={songs}
renderItem={({ item }) => <SongRow song={item} />}
contentContainerStyle={{ paddingBottom: insets.bottom + TAB_BAR_HEIGHT }}
/>
);
}Scaffold(
extendBody: true, // the body runs under the bottom bar
bottomNavigationBar: const FloatingTabBar(),
body: Stack(children: [
const Positioned.fill(child: HeroGradient()), // paints to the edges
SafeArea(
bottom: false, // the list pads its own bottom
child: ListView(
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom + 72),
children: songs.map(SongTile.new).toList(),
),
),
]),
);The safe area doesn't include the keyboard. In react-native-safe-area-context
the insets ignore it, and Compose's Scaffold leaves the IME out of its
default insets. Handle it separately, as in Soft Keyboard.
Keep anything people drag, like a slider or a carousel, out of the bottom
gesture area and the side back-gesture zones too, or the system takes the
swipe before your app does.
Resources
Layout in the Human Interface Guidelines - Apple on safe areas, extending backgrounds under bars, and laying out by size class instead of device.
Edge-to-edge enforcement in Android 15 - Exactly what changes for the status bar, gesture navigation and 3-button navigation when you target API 35.
About window insets - The Compose inset types, safeDrawing, and how Scaffold hands insets to your content.
react-native-safe-area-context - Why SafeAreaView beats the insets hook for most screens, and which edges to apply.
React Native 0.81 - Why core SafeAreaView is deprecated, and how React Native handles Android 16's edge-to-edge.