Safe Areas and the Status Bar

Keep content clear of notches, cutouts and gesture bars with SafeAreaView or useSafeAreaInsets, and control the status bar icons.

Syntaxconst insets = useSafeAreaInsets(); // { top, right, bottom, left } in dp

A phone screen is not all usable. The top is interrupted by a status bar, a notch or a Dynamic Island; the bottom by a home indicator or a gesture bar; the corners are rounded and Android adds camera cutouts. The safe area is what is left. Insets are the distances, in density-independent pixels, that your content must stay clear of.

This matters more than it used to. On Android, Expo SDK 54 and later draw every app edge-to-edge, so your content sits under the system bars by default and the old opt-out has no effect.

Two APIs

Install react-native-safe-area-context and wrap your root in SafeAreaProvider. Then either use its SafeAreaView, which is a View with the insets applied for you, or call useSafeAreaInsets() and apply them yourself.

<SafeAreaView edges={['top']}> is the short form of <View style={{ paddingTop: insets.top }}>. They produce the same layout. edges takes any of top, right, bottom, left, and mode is 'padding' (the default) or 'margin'. Use the hook when the inset has to land somewhere specific, such as the contentContainerStyle of a scroll view.

The status bar

The StatusBar from expo-status-bar controls the icons: style is auto, inverted, light or dark, plus hidden and animated. There is no background colour prop, because under edge-to-edge the bar has no background of its own.

Example

Example · tsx
import { StatusBar } from "expo-status-bar";
import { StyleSheet, Text, View } from "react-native";
// Not from "react-native": that SafeAreaView is deprecated and iOS only.
import { SafeAreaView } from "react-native-safe-area-context";

export default function HomeScreen() {
  return (
    // edges chooses which insets to apply; mode defaults to "padding".
    // Omit edges and all four are applied.
    <SafeAreaView style={styles.screen} edges={["top", "bottom"]}>
      {/* Dark icons, for a light background. "auto" follows the colour
          scheme instead. */}
      <StatusBar style="dark" />
      <View style={styles.header}>
        <Text style={styles.title}>Today</Text>
      </View>
      <View style={styles.body}>
        <Text style={styles.text}>
          This sits below the notch and above the home indicator on any device.
        </Text>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, backgroundColor: "#ffffff" },
  header: {
    paddingHorizontal: 16,
    paddingBottom: 12,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: "#e4e4e7",
  },
  title: { fontSize: 24, fontWeight: "700" },
  body: { flex: 1, padding: 16 },
  text: { fontSize: 15, lineHeight: 22, color: "#3f3f46" },
});

// On screen: the heading clears the notch on an iPhone and the status bar on
// Android, and the content stops above the gesture bar. On a device with no
// notch the insets are smaller and the layout tightens up by itself.

When to use it

  • Stop a heading disappearing behind the notch or the Android status bar.
  • Let the last row of a list scroll clear of the home indicator.
  • Paint a coloured header edge to edge while keeping its text in the safe area.
  • Switch the status bar icons to light over a dark photographic header.

More examples

The provider at the root

The hooks need this provider above them in the tree, and some project templates already include one. Leave it out and useSafeAreaInsets throws "No safe area value available. Make sure you are rendering <SafeAreaProvider> at the top of your app.", so the symptom is a red screen rather than a silent layout bug; SafeAreaView is implemented natively and still pads correctly without it. initialMetrics is optional and worth passing.

Example · tsx
// App.tsx
import { StatusBar } from "expo-status-bar";
import { StyleSheet, View } from "react-native";
import {
  SafeAreaProvider,
  initialWindowMetrics,
} from "react-native-safe-area-context";

import HomeScreen from "./HomeScreen";

export default function App() {
  return (
    // The hooks need this provider above them: without one,
    // useSafeAreaInsets throws "No safe area value available. Make sure you
    // are rendering <SafeAreaProvider> at the top of your app." SafeAreaView
    // is a native view and keeps applying its padding either way.
    // initialMetrics supplies the first frame's values synchronously, so the
    // layout does not visibly jump on launch.
    <SafeAreaProvider initialMetrics={initialWindowMetrics}>
      <View style={styles.root}>
        <StatusBar style="auto" />
        <HomeScreen />
      </View>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: "#ffffff" },
});

The hook, so a scroll view keeps scrolling

On screen: a blue header whose colour runs right up under the status bar while its text sits below it, and a list whose last row can be scrolled above the gesture bar. This is the case SafeAreaView cannot express: wrapping the ScrollView in one pads the outside, so content scrolls under an opaque band at the top. Inline styles are correct here because the value is only known at runtime.

Example · tsx
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

const ITEMS = Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`);

export default function FeedScreen() {
  // A hook: it must be called at the top level of the component, never
  // inside a condition, a loop or after an early return.
  const insets = useSafeAreaInsets();

  return (
    <View style={styles.screen}>
      {/* The header is painted edge to edge and pads itself down. */}
      <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
        <Text style={styles.title}>Feed</Text>
      </View>
      <ScrollView
        style={styles.scroll}
        // The inset goes in the content, not around the scroll window, so
        // the last row can scroll clear of the gesture bar instead of being
        // cut off by a padded parent.
        contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
      >
        {ITEMS.map((item) => (
          <Text key={item} style={styles.row}>
            {item}
          </Text>
        ))}
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, backgroundColor: "#ffffff" },
  header: { paddingHorizontal: 16, paddingBottom: 12, backgroundColor: "#1d4ed8" },
  title: { fontSize: 20, fontWeight: "700", color: "#ffffff" },
  scroll: { flex: 1 },
  row: { paddingVertical: 14, paddingHorizontal: 16, fontSize: 16 },
});

A full-bleed image header

On screen: the photo fills the top of the display including the area behind the clock and the notch, with white status bar icons over it, while the text below still respects the bottom inset. Skipping an edge deliberately is normal for hero images, maps and video. Keep anything readable or tappable out of that strip, and set the status bar style to suit whatever is now behind it.

Example · tsx
import { StatusBar } from "expo-status-bar";
import { Image, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

type DetailScreenProps = {
  heroUri: string;
  title: string;
};

export default function DetailScreen({ heroUri, title }: DetailScreenProps) {
  return (
    // No "top" edge: the photo is meant to run under the status bar.
    <SafeAreaView style={styles.screen} edges={["bottom", "left", "right"]}>
      {/* Light icons, because they sit on a dark photograph. */}
      <StatusBar style="light" animated />
      <Image source={{ uri: heroUri }} style={styles.hero} resizeMode="cover" />
      <View style={styles.body}>
        <Text style={styles.title}>{title}</Text>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, backgroundColor: "#09090b" },
  hero: { width: "100%", height: 280 },
  body: { flex: 1, padding: 16 },
  title: { fontSize: 22, fontWeight: "700", color: "#ffffff" },
});

Discussion

  • Be the first to comment on this lesson.