The mental model: insets are consumed, not inherited
useSafeAreaInsets() always reports the window’s insets — the notch, the status bar, the home indicator. It does not know what any of your parent components already did about them. If a stack header has already pushed your screen down past the notch, the hook still returns the full top inset, and adding it again is how you get a screen with a suspicious band of empty space at the top.
So the rule that prevents most safe-area bugs: exactly one component owns each edge. Decide which one, and make every other component in that column declare that it is not handling it.
The five APIs and when each is right
| API | Use it for | Watch out |
|---|---|---|
SafeAreaProvider | Mount once, above everything, at the app root | Nothing below it reports insets without it |
SafeAreaView | A screen container that needs inset padding on named edges | Always pass edges — the default is all four |
useSafeAreaInsets() | Floating buttons, scroll content padding, custom tab bars | Re-renders on rotation and on inset change |
useSafeAreaFrame() | The usable window rect, excluding system UI | Rarely needed — reach for it only for full-bleed math |
initialWindowMetrics | Pass to the provider to skip the first-frame measure | Removes a visible jump on cold start |
The provider goes above everything, and it should be given initialWindowMetrics. Without it the first render happens with zero insets and the second render corrects them — one frame of the whole screen jumping down, which is invisible in development and extremely visible on a cold start behind a splash screen that has just been hidden.
// app/_layout.tsx
import { Stack } from 'expo-router';
import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context';
export default function RootLayout() {
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Stack />
</SafeAreaProvider>
);
}The double-padding trap
React Navigation already applies insets to its own chrome. A stack header handles the top. A bottom tab bar handles the bottom. So a screen that sits under both should handle neither — and a screen with a hidden header should handle the top itself. Spell it out with edges:
import { SafeAreaView } from 'react-native-safe-area-context';
// Screen INSIDE a stack with a visible header, above a tab bar:
// both vertical edges are already handled. Only the sides matter,
// and those only on notched devices in landscape.
<SafeAreaView edges={['left', 'right']} style={{ flex: 1 }}>
<Content />
</SafeAreaView>
// Full-bleed screen with headerShown: false — you own the top now.
<SafeAreaView edges={['top', 'left', 'right']} style={{ flex: 1 }}>
<Hero />
</SafeAreaView>
// Modal route presented over everything, no tab bar underneath.
<SafeAreaView edges={['top', 'bottom']} style={{ flex: 1 }}>
<Form />
</SafeAreaView>SafeAreaView with no edges prop applies all four. That default is the single largest source of double padding in React Native apps, because it is also the thing every tutorial pastes. Treat a bare <SafeAreaView> in a code review as a question, not a component.
Floating elements: where it becomes triple-counting
The worst version of this bug is a floating tab bar — the pill that hovers above the bottom edge. It is easy to end up adding insets.bottom three times: once by the tab navigator, which offsets its content for the tab bar height plus the inset; once by a SafeAreaView wrapping the screen with default edges; and once inside the pill component itself, which reads the hook to lift itself off the home indicator. On a device with no home indicator all three are zero and everything looks fine, which is why this ships.
The fix is to decide that the floating element owns the bottom edge and nothing else does:
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const BAR_HEIGHT = 64;
const BAR_MARGIN = 12;
export function FloatingTabBar({ children }) {
const insets = useSafeAreaInsets();
// The ONE place insets.bottom is added.
return (
<View style={[styles.bar, { bottom: insets.bottom + BAR_MARGIN }]}>{children}</View>
);
}
// The screen below it: the bar floats, so it does not push content.
// Reserve the space in the SCROLL padding, not with a View.
export function Screen({ data }) {
const insets = useSafeAreaInsets();
return (
<FlatList
data={data}
renderItem={renderItem}
contentContainerStyle={{
paddingBottom: insets.bottom + BAR_MARGIN + BAR_HEIGHT + 16,
}}
/>
);
}Note that the last row of the list is protected by contentContainerStyle.paddingBottom, not by wrapping the list in a padded view. Padding the container instead would clip the scroll area, so the list would stop short of the bottom of the screen and content would visibly vanish under the edge rather than scrolling past it — the second-most-common safe-area complaint after double padding.
Four places insets silently come back zero
- Inside a React Native Modal. A
Modalrenders into its own native window, outside your React root, so the provider above your app is not above it. Insets read inside the modal are zero and your close button lands under the notch. Mount a secondSafeAreaProviderinside the modal, or use a router modal route or a bottom sheet, both of which stay inside the tree. The modal guide walks through the trade-off. - In tests. Render a component with
@testing-library/react-nativeand every inset is zero, so any snapshot asserting layout is asserting a device that does not exist. Wrap the render in<SafeAreaProvider initialMetrics={fakeMetrics}>with realistic numbers. - In a web preview inside an iframe. On React Native Web the insets come from CSS
env(safe-area-inset-*), and those resolve to zero for a document that is not itself at the window edge. Every browser-based preview of a mobile app — including the ones inside AI app builders — shows flat insets for this reason. It is a preview artifact, not a bug in your layout, and the only way to confirm bottom spacing is a real device. - Above the provider. Obvious once stated, easy to do: a root error boundary, a theme provider that renders chrome of its own, or an app-wide banner mounted as a sibling of
SafeAreaProviderrather than inside it. Put the provider first, always.
Android is no longer the easy platform
For years the shortcut was to treat insets as an iOS concern and give Android a fixed status-bar height. Edge-to-edge ended that: on recent Android versions the system bars draw over your content by default, so the top and bottom insets are real numbers you have to respect or the system navigation bar sits on top of your primary button.
Practically, this means testing on an Android device with gesture navigation and one with three-button navigation, because they report meaningfully different bottom insets. It also means left and right stop being ignorable in landscape on notched hardware — the reason edges={['left', 'right']} appears even on screens whose vertical edges are handled elsewhere.
A checklist worth keeping
SafeAreaProviderat the root, withinitialMetrics.- Every
SafeAreaViewpasses an explicitedgesarray. - Bottom inset added in exactly one component per screen.
- Scroll views reserve bottom space with
contentContainerStyle, not container padding. - Anything inside a core Modal gets its own provider.
- Verified on a notched iPhone, a flat-screen Android, and one gesture-navigation Android.
Start from a layout that already handles this
Safe-area wiring is the kind of work that is invisible when correct and embarrassing when wrong, and it is identical in every project. ShipNative generates a real React Native app from a sentence with the provider, the edge assignments, and tab-bar spacing already in place — then previews it on your actual phone, which is the only place safe areas can be confirmed. Export the full Expo project whenever you want to take it from there.