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 IonPage → IonHeader / IonContent /
IonFooter, most of it survives edge-to-edge untouched. Breakage almost
always comes from three places:
- Custom fixed-position elements (FABs, banners, sticky CTAs, splash overlays)
- Content rendered outside
ion-content— a raw<div>between header and footer - A
viewportmeta 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 defaultresize: '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
fitsSystemWindowsplusenv()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.setStyleper 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.