Skip to main content
skill CX Codex

safe-area-handling — Claude Code skill: notches and system bars

Fixes Capacitor layouts where content sits under the notch, Dynamic Island, home indicator or the Android gesture bar.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add Cap-go/capgo-skills --skill safe-area-handling -a claude-code
Open on GitHub

What safe-area-handling does

The skill walks through why a header slides under the status bar and a tab bar hides behind the gesture bar, then tells you what to change. It lays out inset behaviour per platform and version: on iOS the WebView is full-screen and env(safe-area-inset-*) only works with viewport-fit=cover; on Android with Capacitor 8+ the android.adjustMarginsForEdgeToEdge config is gone and the SystemBars core plugin owns insets.

Android 15 and 16 get their own section. At targetSdk 36 edge-to-edge is enforced, so overlaysWebView and backgroundColor from @capacitor/status-bar stop doing anything. On Android WebView below 140 env() returns zero because of a Chromium bug, which is why SystemBars injects --safe-area-inset-* variables when insetsHandling is 'css'. The advice is to build the fallback chain once at the layout shell instead of per component.

The traps list is the useful part. Pairing ios.contentInset: 'always' with CSS safe-area padding applies insets twice. Hardcoded 20/44/47/59 px heights break, since they vary by device, orientation and window size. Insets change at runtime on rotation, in iPad windowed mode and in Android split screen, so caching them at startup is a mistake. Ionic and Konsta already handle insets themselves, so this skill would only get in the way there.

What it helps with

  • keep content out from under the status bar and Dynamic Island
  • pull a tab bar back above the Android gesture bar
  • repair layout after upgrading to Capacitor 8 or targetSdk 35+
  • get non-zero insets on Android WebView older than 140
  • fix side padding in landscape and iPad windowed mode

Example prompts for the agent

  • My header sits under the status bar on a Dynamic Island iPhone — fix it with safe-area-handling
  • Layout broke after we moved to targetSdk 36, figure out why
  • env(safe-area-inset-bottom) returns 0 on Android, what now?

How to install safe-area-handling

1Skills CLI (Vercel)

npx skills add Cap-go/capgo-skills --skill safe-area-handling -a claude-code

2Manually: put the files in place

~/.claude/skills/safe-area-handling/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/safe-area-handling/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about safe-area-handling

How do I install safe-area-handling in Claude?

Run npx skills add Cap-go/capgo-skills --skill safe-area-handling -a claude-code. The skill lands at ~/.claude/skills/safe-area-handling/SKILL.md.

What does safe-area-handling do?

It helps you deal with notches, Dynamic Island, the home indicator and system bars in Capacitor apps, from viewport-fit=cover through the SystemBars plugin to enforced edge-to-edge on Android 15/16.

How is it different from capacitor-keyboard?

safe-area-handling covers system bars and cutouts. Keyboard overlap is out of scope — that is capacitor-keyboard; on iOS the bottom inset stays while the keyboard is up, so you need max() with the keyboard height.

Source text

SKILL.md · MIT
---
name: safe-area-handling
description: Fixes notch, Dynamic Island, home indicator, display cutout, and system bar overlap in Capacitor apps. Covers `viewport-fit=cover` + `env(safe-area-inset-*)`, the Capacitor 8 SystemBars core plugin (`insetsHandling`, injected `--safe-area-inset-*` CSS variables for Android WebView < 140, removal of `android.adjustMarginsForEdgeToEdge`), Android 15/16 enforced edge-to-edge (`overlaysWebView` and `backgroundColor` of @capacitor/status-bar no longer work at targetSdk 36), iOS `contentInset`, and insets that change at runtime on rotation, iPad/foldable resizable windows. Use when headers sit under the status bar, tab bars hide behind the gesture bar, `env()` returns 0 on Android, or layout broke after upgrading to Capacitor 8 / targetSdk 35+. Do not use for keyboard overlap (capacitor-keyboard), Ionic/Konsta component theming (ionic-design, konsta-ui), Tailwind utility setup (tailwind-capacitor), or iOS multi-window/resizability adoption (capacitor-ios-resizability).
---

# Safe Area Handling in Capacitor

## When to Use

TRIGGER when:
- Content renders under the status bar, notch, Dynamic Island, cutout, home indicator, or Android navigation/gesture bar
- `env(safe-area-inset-*)` is `0px` on Android, or correct only after rotation
- Layout broke after Capacitor 8 upgrade or raising `targetSdkVersion` to 35/36
- `StatusBar.setOverlaysWebView` / `setBackgroundColor` stopped working on Android
- Landscape / split-screen / iPad windowed layouts have wrong side padding

Do not use:
- Keyboard covering inputs: `capacitor-keyboard`
- Ionic `ion-header`/`ion-toolbar` theming: `ionic-design`; Konsta `safeAreas`: `konsta-ui`
- Tailwind plugin/utility setup: `tailwind-capacitor`
- iOS scene sizing, `UIScreen.main`, multi-window: `capacitor-ios-resizability`
- Splash screen colors: `capacitor-splash-screen`

## Facts that drive every decision

| Platform / version | Behavior |
|--------------------|----------|
| iOS (all) | WebView is full-screen. `env(safe-area-inset-*)` works only with `viewport-fit=cover`. `ios.contentInset` defaults to `never`. |
| Android, Capacitor <= 7 | `android.adjustMarginsForEdgeToEdge` config could add native margins. |
| Android, Capacitor 8+ | `adjustMarginsForEdgeToEdge` removed. SystemBars core plugin (in `@capacitor/core`) handles insets. |
| Android 15 (targetSdk 35) | Edge-to-edge enforced; opt-out via `windowOptOutEdgeToEdgeEnforcement` still possible. |
| Android 16 (targetSdk 36, Capacitor 8 default; 37 on Capacitor 9) | Opt-out ignored. `@capacitor/status-bar` `overlaysWebView` and `backgroundColor` have no effect. |
| Android WebView < 140 | `env(safe-area-inset-*)` wrong/0 (Chromium bug). SystemBars injects `--safe-area-inset-*` variables (default `insetsHandling: 'css'`). |

How SystemBars behaves on Android (from core source): if the page has `viewport-fit=cover` **and** WebView >= 140, insets pass through to `env()` and the WebView is edge-to-edge. Otherwise Capacitor pads the WebView natively so content sits between the bars and `env()` reports 0. `--safe-area-inset-*` variables carry correct values in both cases when `insetsHandling` is `css`.

## Workflow

1. **Inspect**: `index.html` viewport meta, `capacitor.config.*` (`ios.contentInset`, `android.adjustMarginsForEdgeToEdge`, `plugins.SystemBars`, `plugins.StatusBar`), `@capacitor/core` + `@capacitor/status-bar` versions, `android/variables.gradle` `targetSdkVersion`, global CSS that sets `padding-top`, UI framework (Ionic / Konsta handle insets themselves).
2. **Decide the design**: full-bleed (draw under bars, pad content) vs. contained (content between bars). Ask the user if mockups are unclear.
3. **Full-bleed (recommended)**: `viewport-fit=cover` + the CSS fallback chain below, applied once at the layout shell, not per component.
4. **Remove legacy config**: delete `android.adjustMarginsForEdgeToEdge`; drop Android uses of `StatusBar.setOverlaysWebView` / `setBackgroundColor` (keep for iOS if wanted); prefer `SystemBars.setStyle` for icon color.
5. **Framework check**: Ionic already applies `--ion-safe-area-*`; Konsta uses `safeAreas`. Do not double-pad.
6. **Verify** on devices (below).

Only load a reference when its topic is in play:
- [references/android-edge-to-edge.md](references/android-edge-to-edge.md) - SystemBars config, status-bar migration, nav bar color, WebView version checks
- [references/css-patterns.md](references/css-patterns.md) - layout shell, tab bars, reading insets in JS, debug overlay, resizable windows

## Baseline

```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
```

```css
:root {
  --sat: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
  --sar: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
  --sab: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
  --sal: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}
.app-header { padding-top: var(--sat); padding-inline: var(--sal) var(--sar); }
.app-tabbar { padding-bottom: var(--sab); padding-inline: var(--sal) var(--sar); }
```

The `--safe-area-inset-*` names are injected by Capacitor's SystemBars on Android; on iOS and web they are undefined, so the `env()` fallback applies.

```ts
import { SystemBars, SystemBarsStyle } from '@capacitor/core';
await SystemBars.setStyle({ style: SystemBarsStyle.Dark }); // light icons on dark background
```

`SystemBarsStyle.Dark` = light content for dark backgrounds; `Light` = dark content. iOS needs `UIViewControllerBasedStatusBarAppearance = YES` (template default).

## Traps

- Missing `viewport-fit=cover` is the #1 cause of `env()` = 0 on iOS and of Capacitor padding the WebView on Android.
- Do not combine `ios.contentInset: 'always'` with CSS safe-area padding: insets apply twice.
- Do not hardcode 20/44/47/59 px status bar heights; heights differ per device, orientation, and window size.
- Insets change at runtime (rotation, iPad windowed mode, foldables, Android split screen, keyboard). Use CSS, or re-read on `resize`; never cache once at startup.
- On iOS the bottom inset stays while the keyboard is visible; use `max()` with keyboard height (see `capacitor-keyboard`).
- `100vh` includes the area under system bars; use `100dvh` or flex layout with a fixed shell.
- Ionic: `ion-content` with `fullscreen` + `ion-header` already pads; adding body padding shifts everything twice.

## Verification

1. `npx cap sync`, run on: iPhone with Dynamic Island, iPhone SE-class (home button, small top inset), iPad (rotate + Stage Manager/windowed), Android 15/16 device or emulator in gesture **and** 3-button navigation, landscape.
2. Check Android WebView version: `adb shell dumpsys package com.google.android.webview | grep versionName` (< 140 relies on injected variables).
3. In Safari Web Inspector / `chrome://inspect`, run:

```js
getComputedStyle(document.documentElement).getPropertyValue('--sat');
document.querySelector('meta[name=viewport]').content.includes('viewport-fit=cover');
```

4. Confirm no element overlaps bars; tab bar taps work above the gesture bar; no double padding.
5. Grep for leftovers:

```bash
grep -rn "adjustMarginsForEdgeToEdge\|setOverlaysWebView\|setBackgroundColor\|windowOptOutEdgeToEdgeEnforcement" capacitor.config.* src android/app/src/main/res 2>/dev/null
```

## Error Handling

| Symptom / message | Cause | Fix |
|-------------------|-------|-----|
| Header under status bar on iOS, `env()` = 0 | No `viewport-fit=cover` | Add to viewport meta |
| Android: content correct but no edge-to-edge, `env()` = 0 | No `viewport-fit=cover` or WebView < 140, so Capacitor pads natively | Add `viewport-fit=cover`; use `--safe-area-inset-*` fallback |
| Android: double gap at top | Native padding + your CSS padding with `insetsHandling: 'disable'` or custom MainActivity insets code | Remove custom `setDecorFitsSystemWindows` / inset listeners; keep SystemBars default |
| `StatusBar.setBackgroundColor` / `setOverlaysWebView` no effect on Android | targetSdk 35+/36 edge-to-edge | Draw a background under the bar in CSS; use `SystemBars.setStyle` for icon color |
| `android.adjustMarginsForEdgeToEdge` still in config after Capacitor 8 upgrade, margins gone | Option removed and ignored | Delete it; rely on SystemBars + CSS |
| Logcat `Unknown insetsHandling value '...'. Falling back to 'css'.` | Invalid `SystemBars.insetsHandling` | Use a value listed in the current docs (`css`, `disable`; newer cores also accept `native`) |
| Status bar text invisible (white on white) | Style mismatch | `SystemBars.setStyle({ style: SystemBarsStyle.Light })` |
| Layout jumps on first paint (Android) | SystemBars detects `viewport-fit` after page commit | Set `plugins.SystemBars.initialViewportFitValueHint: 'cover'` (in core config declarations; verify in your version) |

## Resources

- System Bars API: https://capacitorjs.com/docs/apis/system-bars
- Status Bar API (Android 16 note): https://capacitorjs.com/docs/apis/status-bar
- Updating to 8.0: https://capacitorjs.com/docs/updating/8-0
- Android edge-to-edge: https://developer.android.com/develop/ui/views/layout/edge-to-edge
- WebKit viewport-fit / env(): https://webkit.org/blog/7929/designing-websites-for-iphone-x/

© Cap-go · MIT · Cap-go/capgo-skills

Similar skills

Everything for Mobile →

From the same set29

capacitor-plugins Picks the right Capacitor plugin for a native feature, installs it with a matching major, and proves it is linked on iOS and Android. CX Codex +1 1,600installs ionic-design Builds and reviews Ionic Framework UI components for Capacitor apps. Helps configure themes, iOS and MD modes, and handle migrations from Ionic 8 to Ionic 9. CX Codex +1 1,600installs ios-android-logs A skill that shows where Capacitor logs and crash reports live on iOS and Android, and how to narrow the output down to your own process. CX Codex +1 1,300installs capacitor-testing A skill that picks the right layer for testing a Capacitor app: Vitest with mocked plugins, Playwright, Appium, or native unit tests. CX Codex +1 1,300installs capacitor-app-store Walks a Capacitor build from "runs on my device" to live in the App Store and Google Play: versions, signing, IPA/AAB upload, TestFlight and Play tracks. CX Codex +1 1,200installs capacitor-accessibility A skill that walks an AI agent through accessibility in Capacitor apps: VoiceOver, TalkBack, Dynamic Type, Switch Control and Android font scale. CX Codex +1 1,200installs capacitor-security Audits the app layer of a Capacitor project for leaked secrets, weak on-device storage and cleartext traffic, then walks through the fixes. CX Codex +1 1,200installs capacitor-push-notifications A skill for setting up and debugging remote push in Capacitor apps: FCM on Android, APNs on iOS. Useful when registration never fires or notifications don't show. CX Codex +1 1,100installs

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor