Skip to content

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.

  • Slow MorningMorning Mix
  • Paper BoatsMorning Mix
  • NorthboundMorning Mix
  • GlasshouseMorning Mix
  • Late BloomMorning Mix
  • TidewaterMorning Mix
  • Small HoursMorning Mix
  • Field NotesMorning Mix
  • AfterglowMorning Mix
  • Low LightMorning Mix
  • Copper SkyMorning Mix
  • HomewardMorning Mix
  • Last SongThe last row. Can you tap 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

PlaylistView.swift
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

Search concepts

Search for a command to run...