+1 (415) 943-4271

Edge-to-Edge Is Not Optional Anymore: Handling Android Insets in React Native

Google's edge-to-edge push has quietly become one of the most common sources of "it looks broken on Android" bug reports we see when we join an existing React Native project. Apps that were pixel-perfect for years suddenly ship a header tucked under the status bar, a sticky CTA sitting beneath the gesture pill, or a bottom tab bar that eats its own labels.

The reason is simple: edge-to-edge used to be something you opted into. Now it is the default, and with recent target SDK levels the windowOptOutEdgeToEdgeEnforcement escape hatch is deprecated and on its way out. Your layout has to be inset-aware. This tutorial walks through how we do it on client projects.

What "edge-to-edge" actually changes

When edge-to-edge is enforced, the system stops reserving space for you. Your root view is laid out behind:

  • the status bar at the top,
  • the navigation bar or gesture pill at the bottom,
  • display cutouts (notches, hole punches) on the sides in landscape,
  • and, if you handle it, the keyboard (IME).

Nothing about your JSX changes. What changes is that the pixels behind those bars are now yours, and it is your job to pad content out of the way. Android exposes that padding as window insets; React Native surfaces them through react-native-safe-area-context.

Step 1: make the native side explicit

If you are on Expo, edge-to-edge is enabled for you on recent SDKs and react-native-edge-to-edge is already in the tree. Keep it explicit in app.json so nobody has to guess:

{
  "expo": {
    "android": {
      "edgeToEdgeEnabled": true
    },
    "androidStatusBar": {
      "translucent": true,
      "barStyle": "dark-content"
    }
  }
}

On a bare React Native app, install react-native-edge-to-edge and apply its theme rather than hand-rolling WindowCompat calls in MainActivity:

<!-- android/app/src/main/res/values/styles.xml -->
<style name="AppTheme" parent="Theme.EdgeToEdge.Material3">
  <item name="android:windowLightStatusBar">true</item>
</style>

This gives you consistent behaviour across Android 10 through the current release instead of a per-version patchwork.

Step 2: wrap the app once, consume insets everywhere

// index.tsx
import { SafeAreaProvider } from 'react-native-safe-area-context';

export default function Root() {
  return (
    <SafeAreaProvider>
      <App />
    </SafeAreaProvider>
  );
}

Then stop using the old SafeAreaView from react-native. It is iOS-only and does nothing for Android insets. Use the edges prop from safe-area-context, and only for the edges a given screen actually owns:

import { SafeAreaView } from 'react-native-safe-area-context';

export function ProfileScreen() {
  return (
    <SafeAreaView edges={['top', 'bottom']} style={{ flex: 1 }}>
      <ProfileHeader />
      <ProfileBody />
    </SafeAreaView>
  );
}

The edges prop matters more than people expect. If a screen sits inside a stack navigator that already applies the top inset, applying it again gives you a double gap. Our rule of thumb: the outermost component that draws a background owns the inset.

Step 3: use padding, not margin, for scroll containers

The most common regression we fix is a FlatList whose last row is unreachable behind the gesture bar. Do not wrap the list in a safe-area view — that clips the scroll area and kills the nice "content scrolls under a translucent bar" effect. Pad the content instead:

import { useSafeAreaInsets } from 'react-native-safe-area-context';

export function Feed({ data }) {
  const insets = useSafeAreaInsets();

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      contentContainerStyle={{
        paddingTop: insets.top + 12,
        paddingBottom: insets.bottom + 24,
      }}
      scrollIndicatorInsets={{ top: insets.top, bottom: insets.bottom }}
    />
  );
}

Same idea for a floating action button or sticky footer: bottom: insets.bottom + 16 rather than a hardcoded 32, which is wrong on both a three-button navigation bar and a tablet.

Step 4: keyboards are an inset too

Once the window is edge-to-edge, softwareKeyboardLayoutMode and KeyboardAvoidingView guesswork gets flaky. The reliable path today is react-native-keyboard-controller, which reads the real IME inset and animates in lockstep with the system:

import {
  KeyboardAvoidingView,
  KeyboardProvider,
} from 'react-native-keyboard-controller';

<KeyboardProvider>
  <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
    <MessageList />
    <Composer />
  </KeyboardAvoidingView>
</KeyboardProvider>;

If you must stay on core components, at least set android:windowSoftInputMode="adjustResize" and test with both the stock keyboard and a third-party one (Gboard and SwiftKey behave differently on tall devices).

Step 5: style the bars instead of hiding them

Edge-to-edge means the bars are transparent, so your content colour decides legibility. Two knobs:

import { StatusBar } from 'expo-status-bar';
import * as SystemUI from 'expo-system-ui';
import * as NavigationBar from 'expo-navigation-bar';

<StatusBar style="dark" />; // icon colour, not background
await SystemUI.setBackgroundColorAsync('#FFFFFF');
await NavigationBar.setButtonStyleAsync('dark');

Note what is not here: setting a background colour on the status bar. That API is deprecated under enforcement and will be ignored. If you need a coloured header behind the status bar, draw it yourself — a View with height: insets.top and your brand colour, sitting above the header.

Step 6: dark mode and dynamic colour

Icon colours have to follow the theme, not a constant. A small hook keeps it honest:

const scheme = useColorScheme();
useEffect(() => {
  NavigationBar.setButtonStyleAsync(scheme === 'dark' ? 'light' : 'dark');
}, [scheme]);

We also recommend auditing any screen with a photo or video hero: white status bar icons over a light image are invisible, and edge-to-edge makes that scenario far more common.

QA checklist we run before shipping

Copy this into your release ticket template:

  1. Gesture navigation and three-button navigation on the same device (Settings → System → Navigation mode).
  2. A device with a display cutout, in landscape — check horizontal insets, not just top/bottom.
  3. Large font / display size at maximum accessibility setting.
  4. Every bottom-anchored control: tabs, FABs, sticky CTAs, toasts, snackbars.
  5. Every scrollable list: can you reach the last item and tap it?
  6. Keyboard open on every form, with the submit button visible.
  7. Modals and bottom sheets, which frequently ship their own broken inset handling.
  8. Split-screen / multi-window, where insets change at runtime.
  9. Dark mode, for bar icon contrast.
  10. Foldables — inset values change on fold/unfold, so make sure nothing caches insets in a ref.

Common failure patterns

  • Hardcoded StatusBar.currentHeight. It is wrong on cutout devices and does not react to configuration changes. Use insets.top.
  • SafeAreaView from react-native. No-op on Android. Search your codebase for it today.
  • Double insets from nesting safe-area views inside navigators that already inset.
  • Insets read outside SafeAreaProvider returning zeros — common in code that renders into a separate root, like a native modal or a toast portal.
  • Third-party UI kits pinned to old versions that predate enforcement. Check your bottom-sheet and tab-bar libraries first; they are the usual suspects.

Why this is worth a dedicated pass

Edge-to-edge issues rarely crash anything, so they slip past automated tests and land in store reviews instead. A focused half-day audit — root provider, edges props, scroll padding, keyboard, bar styling — usually clears the whole class of bug. Done properly, your app also looks noticeably more modern: content flowing under translucent bars is what current Android design expects.

If you are staring at a large existing codebase and would rather not hunt inset bugs screen by screen, get in touch. Inset and target-SDK audits are a routine part of our React Native consulting work, and they pair naturally with a New Architecture migration if you have one on the roadmap.