A phone screen is not a rectangle you own. A notch, a camera cut-out, the home indicator and rounded corners all eat into it, and content placed at the top of the window is drawn underneath them.
There is no web equivalent, and no amount of flexbox solves it — the amounts differ per device and per orientation, so they cannot be constants.
useSafeAreaInsets
The react-native-safe-area-context library reports how much to keep clear on each
edge. It is a hook, so the values update when the device rotates:
const insets = useSafeAreaInsets();
const paddingBottom = insets.bottom + theme.spacing.lg + bottomInset;Four numbers — top, bottom, left, right. On
an iPhone with a home indicator, bottom is about 34; on an older device it is 0.
The provider has to wrap the app for the hook to work. It is the outermost provider in the demo app for exactly that reason — the screen shell and the toast host both read insets, so it has to sit above both.
Applying it once, not per screen
Every screen needs this, so no screen should implement it. Wrap it:
return (
<ScrollView
testID={testID}
style={styles.screen}
contentContainerStyle={[padded && styles.padded, { paddingBottom }, contentStyle]}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{children}
</ScrollView>
);Only the bottom inset is applied by default here, which is worth explaining. The
navigation header already handles the top — it draws itself below the notch — so adding
insets.top as well would leave a visible gap. The bottom is the one that matters: a
"Pay" button flush against the bottom edge sits under the home indicator and is genuinely hard to
press.
style versus contentContainerStyle
A ScrollView has two, and confusing them is a rite of passage.
style is the scroll view itself — the window you look through, where
flex: 1 belongs. contentContainerStyle is the content that moves inside
it, and it is where padding belongs. Put padding on style and it pads the window,
clipping the content instead of insetting it.
SafeAreaView, and why it is not enough
There is a SafeAreaView component that applies insets as padding automatically. It
is fine for simple cases and has two real limits: it is iOS-only in React Native's own
implementation, and it applies all four edges, which is rarely what you want. Reading the insets and
deciding yourself is more code and far more predictable.
The keyboard
The second thing that takes your screen away. When it opens, roughly 40% of the display is gone and whatever the user was typing into may now be behind it.
<KeyboardAvoidingView
style={styles.fill}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>⚠️ The platform split is required, not defensive. Android resizes the app
window itself when the keyboard appears, so 'height' cooperates with that. iOS does
not, so it needs 'padding'. Using one value for both gives you a screen that works
correctly on one platform and looks broken on the other — and if you only ever test on a simulator,
you will not see which.
Dismissing it
There is no click-outside-to-blur. Two props on the scroll view handle it:
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"keyboardDismissMode="on-drag" closes the keyboard when the user scrolls, which is
what they expect.
keyboardShouldPersistTaps is the subtle one and it fixes a real complaint. The
default is 'never': while the keyboard is open, the first tap anywhere is consumed to
dismiss it — so tapping a button does nothing and the user has to tap twice.
'handled' means a tap that something else handles goes through, while a tap on empty
background still dismisses. You want both, and only this value gives you both.
Where the pieces sit
Worth stating plainly, because the layering trips people up. The safe-area provider wraps everything. The navigation container handles the top inset via its header. Each screen applies the bottom inset. A modal or a bottom sheet is its own window, so it reads the insets again itself — being inside a screen that already applied them does not help it.
What is next
Lists with FlatList — and why
ScrollView is the wrong answer past about a dozen rows.