+1 (415) 843-4662

Edge-to-edge and safe areas: making Ionic apps look right on Android 16 and iOS 26

Two platform changes landed close together, and between them they broke the layout of a lot of hybrid apps.

On Android, edge-to-edge is no longer optional. Apps targeting SDK 35 got it by default with an opt-out; apps targeting SDK 36 lose the opt-out, so your WebView now runs behind the status bar and the navigation bar whether you asked for it or not. On iOS, the refreshed translucent system chrome means more of the system UI floats above your content instead of pushing it down, and toolbars that looked fine on iOS 17 can now sit under a blurred layer.

The symptoms are always the same: a header title tucked under the clock, a bottom tab bar half-swallowed by the gesture pill, a fixed footer button that is impossible to tap, or a modal whose close button lands under the notch. This tutorial fixes all of that properly — not with a hard-coded padding-top: 44px.

1. Understand what Ionic already does for you

Ionic components are inset-aware. ion-header, ion-footer, ion-tab-bar, ion-modal and ion-content all consume the CSS environment variables that the WebView exposes:

env(safe-area-inset-top)
env(safe-area-inset-bottom)
env(safe-area-inset-left)
env(safe-area-inset-right)

Ionic wraps them in its own custom properties, --ion-safe-area-top and friends, so you can override them for testing.

So if your app is built from IonPageIonHeader / IonContent / IonFooter, most of it survives edge-to-edge untouched. Breakage almost always comes from three places:

  1. Custom fixed-position elements (FABs, banners, sticky CTAs, splash overlays)
  2. Content rendered outside ion-content — a raw <div> between header and footer
  3. A viewport meta tag that never opted into insets in the first place

2. The one line most broken apps are missing

env(safe-area-inset-*) returns 0px unless the viewport is configured to cover the whole display. Check index.html:

<meta
  name="viewport"
  content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>

viewport-fit=cover is the important part. If it is absent, every safe-area calculation in your CSS silently evaluates to zero and you will chase the bug for an afternoon. Ionic's starter templates include it; apps that have been through five years of hand-edits often do not.

3. Configure the native shells

Android

Edge-to-edge is drawn by the system; your job is to stop fighting it and let the WebView report insets. In capacitor.config.ts:

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.app',
  appName: 'Example',
  webDir: 'dist',
  plugins: {
    StatusBar: {
      overlaysWebView: true,
      style: 'DEFAULT',
      backgroundColor: '#00000000',
    },
    Keyboard: {
      resize: 'native',
    },
  },
};

export default config;

Then make sure your theme is not painting opaque system bars underneath the transparent WebView. In android/app/src/main/res/values/styles.xml:

<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
  <item name="windowActionBar">false</item>
  <item name="windowNoTitle">true</item>
  <item name="android:background">@null</item>
  <item name="android:statusBarColor">@android:color/transparent</item>
  <item name="android:navigationBarColor">@android:color/transparent</item>
  <item name="android:windowLightStatusBar">true</item>
</style>

On recent Android versions statusBarColor and navigationBarColor are ignored anyway — which is exactly the point of the change. Remove any legacy calls that tried to set them from Java/Kotlin, and remove any android:fitsSystemWindows="true" you find on the WebView container; that attribute is what makes insets disappear and content look correct on your test device but wrong on everyone else's.

If you use a community edge-to-edge helper plugin, keep exactly one such plugin. Two plugins both applying insets is the most common cause of doubled padding — a 96px gap under the status bar.

iOS

iOS has honoured viewport-fit=cover for years, so the work here is smaller. What changed with the translucent chrome is contrast: content now scrolls under a blurred toolbar, so a header that relied on an opaque background may look washed out.

// AppComponent / bootstrap
import { StatusBar, Style } from '@capacitor/status-bar';
import { Capacitor } from '@capacitor/core';

if (Capacitor.getPlatform() === 'ios') {
  await StatusBar.setStyle({ style: Style.Default }); // follows light/dark
}

Give ion-toolbar a solid or explicitly translucent background rather than leaving it to inherit, and verify both light and dark appearance. If you use translucent toolbars, ion-content must be fullscreen for the blur to have anything to blur:

<ion-header translucent="true">
  <ion-toolbar>
    <ion-title>Orders</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content fullscreen="true">
  <!-- content scrolls under the blurred header -->
</ion-content>

<ion-footer translucent="true">
  <ion-toolbar>
    <ion-button expand="block">Checkout</ion-button>
  </ion-toolbar>
</ion-footer>

4. Fix your own fixed elements

Everything custom that is pinned to a screen edge needs an inset-aware offset. Define two utility classes once and use them everywhere:

:root {
  --app-inset-top: env(safe-area-inset-top, 0px);
  --app-inset-bottom: env(safe-area-inset-bottom, 0px);
}

.safe-top {
  padding-top: calc(12px + var(--app-inset-top));
}

.safe-bottom {
  padding-bottom: calc(12px + var(--app-inset-bottom));
}

A sticky CTA that used to be bottom: 16px:

.sticky-cta {
  position: fixed;
  left: 16px;
  right: 16px;
  bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  z-index: 20;
}

A custom FAB above a tab bar needs both the tab bar height and the inset:

.fab-above-tabs {
  bottom: calc(56px + 16px + env(safe-area-inset-bottom, 0px));
}

And a full-screen overlay — onboarding, a camera viewfinder, a signature pad — should pad on all four sides:

.overlay {
  position: fixed;
  inset: 0;
  padding:
    env(safe-area-inset-top, 0px)
    env(safe-area-inset-right, 0px)
    env(safe-area-inset-bottom, 0px)
    env(safe-area-inset-left, 0px);
}

The left/right insets are not decorative: they are what keeps controls off the rounded corners and the camera housing in landscape.

5. The keyboard, which is where it usually goes wrong

Edge-to-edge plus the keyboard is the combination that produces the worst bugs — a text input hidden behind the keyboard, or a footer that jumps 200px when the keyboard opens.

Rules that have held up across our projects:

  • Put inputs inside ion-content, and let Ionic scroll them into view. Custom scroll containers with their own keyboard handling almost always regress.
  • On Android use resize: 'native' and let the system inset the WebView; on iOS the default resize: 'native' behaviour is also correct in current Capacitor.
  • When the keyboard is visible, drop the bottom safe-area padding — the gesture inset no longer applies:
import { Keyboard } from '@capacitor/keyboard';

Keyboard.addListener('keyboardWillShow', () => {
  document.body.classList.add('keyboard-open');
});

Keyboard.addListener('keyboardWillHide', () => {
  document.body.classList.remove('keyboard-open');
});
body.keyboard-open .safe-bottom,
body.keyboard-open .sticky-cta {
  padding-bottom: 12px;
  bottom: 12px;
}

Remember to remove listeners on teardown in a framework component, and to guard the plugin calls on web where Keyboard is a no-op.

6. Modals, sheets and popovers

ion-modal handles insets for the standard presentation. Sheet modals (breakpoints + initialBreakpoint) need care because the sheet's own handle area and the gesture inset stack:

<ion-modal
  [isOpen]="open"
  [breakpoints]="[0, 0.5, 0.9]"
  [initialBreakpoint]="0.5"
  [handle]="true"
>
  <ng-template>
    <ion-header>
      <ion-toolbar>
        <ion-title>Filters</ion-title>
        <ion-buttons slot="end">
          <ion-button (click)="open = false">Close</ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>
    <ion-content class="ion-padding safe-bottom">
      <!-- sheet body -->
    </ion-content>
  </ng-template>
</ion-modal>

If you build custom sheets with a plain div and a transform, you own the insets entirely — this is the single most common place we find hard-coded 34px.

7. Test it where it actually breaks

A checklist we run before every release:

  • iOS: a device with a Dynamic Island, an older device with a plain rectangular status bar, and an iPad. Rotate to landscape on the phone.
  • Android: one device with gesture navigation, one with three-button navigation (the bottom inset differs by ~30px), and one with a hole-punch camera in landscape.
  • Both: largest system font size, dark mode, keyboard open on your longest form, and a phone call / recording banner active — that expands the top inset and is the classic "only reproducible in the wild" bug.

For fast iteration in the browser, override Ionic's variables instead of resizing a simulator:

:root {
  --ion-safe-area-top: 59px;
  --ion-safe-area-bottom: 34px;
}

A Playwright or Cypress screenshot run with those variables set catches most regressions in CI without a device farm.

8. Ship-blocking gotchas

  • Doubled padding — two inset plugins, or fitsSystemWindows plus env() padding. Pick one source of truth.
  • Zero insets — missing viewport-fit=cover, or a WebView that is not actually laid out edge to edge.
  • Splash screen jump — the native splash respects insets, your first web frame does not; pad your app shell's initial view too.
  • Status bar text invisible — set windowLightStatusBar / StatusBar.setStyle per theme, and re-set it when the user toggles dark mode at runtime.
  • Landscape tablets — left/right insets ignored, so buttons sit under the rounded corners.

Getting help

Edge-to-edge work is usually a two-to-five-day job on a mature app: a pass over fixed elements, a native config clean-up, and a device-matrix test round. It is also work that ages badly if it is done with magic numbers, since the numbers change with every device generation.

We do this as part of legacy Ionic app modernization and alongside Ionic 8/9 upgrades, where target-SDK bumps force the issue anyway. If your app has started looking wrong on new devices, get in touch and we will scope the fix.