Safe Area Handling

by cap-go2cfb277a1488No license72 starsListed Oct 8, 2026Updated Oct 8, 2026Repository updated today

Complete guide to handling safe areas in Capacitor apps for iPhone notch, Dynamic Island, home indicator, and Android cutouts. Covers CSS, JavaScript, and native solutions. Use this skill when users have layout issues on modern devices.

Instructions onlySoftware Development
AI-generated overview

Guides handling of safe areas in Capacitor apps for iPhone notch, Dynamic Island, home indicator and Android cutouts.

What it does
Provides a reference guide for fixing layout problems caused by notches, Dynamic Island, home indicators and Android display cutouts in Capacitor apps. It covers CSS environment variables and viewport-fit, JavaScript and framework hooks for reading insets, native iOS and Android configuration, the Capacitor Status Bar plugin, and common issues with solutions. It also includes testing and debug-visualization guidance.
When to use it
Use when users report layout issues on notched or cutout devices, content hidden under the notch or home indicator, or when fullscreen and edge-to-edge layouts are needed.
Requirements
No scripts are included; it is instructions only. Applying the guidance assumes a Capacitor app project with web, iOS and/or Android code, and optionally the @capacitor/status-bar and @capacitor/keyboard packages.

Safe Area Handling in Capacitor

Handle iPhone notch, Dynamic Island, home indicator, and Android cutouts properly.

When to Use This Skill

  • User has layout issues on notched devices
  • User asks about safe areas
  • User sees content under the notch
  • User needs fullscreen layout
  • Content is hidden by home indicator

Understanding Safe Areas

What Are Safe Areas?

Safe areas are the regions of the screen not obscured by:

  • iPhone: Notch, Dynamic Island, home indicator, rounded corners
  • Android: Camera cutouts, navigation gestures, display cutouts

Safe Area Insets

InsetDescription
safe-area-inset-topNotch/Dynamic Island/status bar
safe-area-inset-bottomHome indicator/navigation bar
safe-area-inset-leftLeft edge (landscape)
safe-area-inset-rightRight edge (landscape)

CSS Solution

Enable Viewport Coverage

html
<!-- index.html --><meta  name="viewport"  content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>

Important: viewport-fit=cover is required to access safe area insets.

Using CSS Environment Variables

css
/* Basic usage */.header {  padding-top: env(safe-area-inset-top);}
.footer {  padding-bottom: env(safe-area-inset-bottom);}
/* With fallback */.header {  padding-top: env(safe-area-inset-top, 20px);}
/* Combined with other padding */.content {  padding-top: calc(env(safe-area-inset-top) + 16px);  padding-bottom: calc(env(safe-area-inset-bottom) + 16px);}

Full Page Layout

css
/* App container */.app {  position: fixed;  top: 0;  left: 0;  right: 0;  bottom: 0;  display: flex;  flex-direction: column;}
/* Header respects notch */.header {  padding-top: env(safe-area-inset-top);  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);  background: #fff;}
/* Scrollable content */.content {  flex: 1;  overflow-y: auto;  -webkit-overflow-scrolling: touch;  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);}
/* Footer respects home indicator */.footer {  padding-bottom: env(safe-area-inset-bottom);  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);  background: #fff;}

Tab Bar with Safe Area

css
.tab-bar {  position: fixed;  bottom: 0;  left: 0;  right: 0;  display: flex;  background: #fff;  border-top: 1px solid #eee;
  /* Add padding for home indicator */  padding-bottom: env(safe-area-inset-bottom);}
.tab-bar-item {  flex: 1;  display: flex;  flex-direction: column;  align-items: center;  padding: 8px 0;  min-height: 49px; /* iOS standard height */}

Full-Bleed Background with Safe Content

css
.hero {  /* Background extends to edges */  background: linear-gradient(to bottom, #4f46e5, #7c3aed);  padding-top: calc(env(safe-area-inset-top) + 20px);  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);}
.hero-content {  /* Content stays in safe area */  max-width: 100%;}

JavaScript Solution

Reading Safe Area Values

typescript
function getSafeAreaInsets() {  const computedStyle = getComputedStyle(document.documentElement);
  return {    top: parseInt(computedStyle.getPropertyValue('--sat') || '0'),    bottom: parseInt(computedStyle.getPropertyValue('--sab') || '0'),    left: parseInt(computedStyle.getPropertyValue('--sal') || '0'),    right: parseInt(computedStyle.getPropertyValue('--sar') || '0'),  };}
// Set CSS custom propertiesfunction setSafeAreaProperties() {  const style = document.documentElement.style;
  // Create temporary element to read values  const temp = document.createElement('div');  temp.style.paddingTop = 'env(safe-area-inset-top)';  temp.style.paddingBottom = 'env(safe-area-inset-bottom)';  temp.style.paddingLeft = 'env(safe-area-inset-left)';  temp.style.paddingRight = 'env(safe-area-inset-right)';  document.body.appendChild(temp);
  const computed = getComputedStyle(temp);  style.setProperty('--sat', computed.paddingTop);  style.setProperty('--sab', computed.paddingBottom);  style.setProperty('--sal', computed.paddingLeft);  style.setProperty('--sar', computed.paddingRight);
  document.body.removeChild(temp);}
// Update on orientation changewindow.addEventListener('orientationchange', () => {  setTimeout(setSafeAreaProperties, 100);});

React Hook

typescript
import { useState, useEffect } from 'react';
interface SafeAreaInsets {  top: number;  bottom: number;  left: number;  right: number;}
function useSafeArea(): SafeAreaInsets {  const [insets, setInsets] = useState<SafeAreaInsets>({    top: 0,    bottom: 0,    left: 0,    right: 0,  });
  useEffect(() => {    function updateInsets() {      const temp = document.createElement('div');      temp.style.cssText = `        position: fixed;        top: 0;        padding-top: env(safe-area-inset-top);        padding-bottom: env(safe-area-inset-bottom);        padding-left: env(safe-area-inset-left);        padding-right: env(safe-area-inset-right);      `;      document.body.appendChild(temp);
      const computed = getComputedStyle(temp);      setInsets({        top: parseFloat(computed.paddingTop) || 0,        bottom: parseFloat(computed.paddingBottom) || 0,        left: parseFloat(computed.paddingLeft) || 0,        right: parseFloat(computed.paddingRight) || 0,      });
      document.body.removeChild(temp);    }
    const handleOrientationChange = () => {      setTimeout(updateInsets, 100);    };
    updateInsets();    window.addEventListener('resize', updateInsets);    window.addEventListener('orientationchange', handleOrientationChange);
    return () => {      window.removeEventListener('resize', updateInsets);      window.removeEventListener('orientationchange', handleOrientationChange);    };  }, []);
  return insets;}
// Usagefunction Header() {  const { top } = useSafeArea();
  return (    <header style={{ paddingTop: top }}>      App Header    </header>  );}

Vue Composable

typescript
import { ref, onMounted, onUnmounted } from 'vue';
export function useSafeArea() {  const insets = ref({    top: 0,    bottom: 0,    left: 0,    right: 0,  });
  function updateInsets() {    const temp = document.createElement('div');    temp.style.cssText = `      position: fixed;      padding-top: env(safe-area-inset-top);      padding-bottom: env(safe-area-inset-bottom);      padding-left: env(safe-area-inset-left);      padding-right: env(safe-area-inset-right);    `;    document.body.appendChild(temp);
    const computed = getComputedStyle(temp);    insets.value = {      top: parseFloat(computed.paddingTop) || 0,      bottom: parseFloat(computed.paddingBottom) || 0,      left: parseFloat(computed.paddingLeft) || 0,      right: parseFloat(computed.paddingRight) || 0,    };
    document.body.removeChild(temp);  }
  onMounted(() => {    updateInsets();    window.addEventListener('resize', updateInsets);  });
  onUnmounted(() => {    window.removeEventListener('resize', updateInsets);  });
  return insets;}

Native iOS Configuration

Status Bar Style

typescript
// capacitor.config.tsimport type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {  ios: {    // Content extends behind status bar    contentInset: 'automatic', // or 'always', 'scrollableAxes', 'never'  },};

Extend Behind Safe Areas

swift
// ios/App/App/AppDelegate.swiftimport UIKitimport Capacitor
@UIApplicationMainclass AppDelegate: UIResponder, UIApplicationDelegate {    func application(        _ application: UIApplication,        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?    ) -> Bool {        // Extend content to edges        if let window = UIApplication.shared.windows.first {            window.backgroundColor = .clear        }        return true    }}

Info.plist Settings

xml
<!-- ios/App/App/Info.plist --><!-- Allow full screen content --><key>UIViewControllerBasedStatusBarAppearance</key><true/>
<!-- For landscape support --><key>UISupportedInterfaceOrientations</key><array>    <string>UIInterfaceOrientationPortrait</string>    <string>UIInterfaceOrientationLandscapeLeft</string>    <string>UIInterfaceOrientationLandscapeRight</string></array>

Native Android Configuration

Display Cutout Mode

xml
<!-- android/app/src/main/res/values-v28/styles.xml --><resources>    <style name="AppTheme" parent="Theme.AppCompat.NoActionBar">        <!-- Extend content into cutout area -->        <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>    </style></resources>

Edge-to-Edge Display

kotlin
// android/app/src/main/java/.../MainActivity.ktimport android.os.Buildimport android.view.Viewimport android.view.WindowInsetsimport android.view.WindowInsetsController
class MainActivity : BridgeActivity() {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)
        // Enable edge-to-edge        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {            window.setDecorFitsSystemWindows(false)        } else {            @Suppress("DEPRECATION")            window.decorView.systemUiVisibility = (                View.SYSTEM_UI_FLAG_LAYOUT_STABLE or                View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN            )        }    }}

AndroidManifest Configuration

xml
<!-- android/app/src/main/AndroidManifest.xml --><activity    android:name=".MainActivity"    android:theme="@style/AppTheme"    android:windowSoftInputMode="adjustResize"    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"></activity>

Capacitor Status Bar Plugin

Installation

bash
npm install @capacitor/status-barnpx cap sync

Usage

typescript
import { StatusBar, Style } from '@capacitor/status-bar';
// Set status bar styleawait StatusBar.setStyle({ style: Style.Dark });
// Set background color (Android)await StatusBar.setBackgroundColor({ color: '#ffffff' });
// Show/hide status barawait StatusBar.hide();await StatusBar.show();
// Overlay modeawait StatusBar.setOverlaysWebView({ overlay: true });

Common Issues and Solutions

Issue: Content Behind Notch

Solution: Add viewport-fit and safe area padding

html
<meta name="viewport" content="viewport-fit=cover">
css
body {  padding-top: env(safe-area-inset-top);}

Issue: Tab Bar Under Home Indicator

Solution: Add bottom safe area padding

css
.tab-bar {  padding-bottom: env(safe-area-inset-bottom);}

Issue: Landscape Layout Broken

Solution: Handle left/right insets

css
.content {  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);}

Issue: Keyboard Pushes Content

Solution: Use adjustResize and handle insets dynamically

typescript
import { Keyboard } from '@capacitor/keyboard';
Keyboard.addListener('keyboardWillShow', (info) => {  document.body.style.paddingBottom = `${info.keyboardHeight}px`;});
Keyboard.addListener('keyboardWillHide', () => {  document.body.style.paddingBottom = 'env(safe-area-inset-bottom)';});

Issue: Safe Areas Not Working in WebView

Cause: Missing viewport-fit=cover

Solution:

html
<!-- Must be exactly like this --><meta  name="viewport"  content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>

Testing Safe Areas

iOS Simulator

  1. Use iPhone with notch (iPhone 14 Pro, etc.)
  2. Test both portrait and landscape
  3. Test with keyboard visible

Android Emulator

  1. Create emulator with camera cutout
  2. Test navigation gesture mode
  3. Test 3-button navigation mode

Preview Different Devices

css
/* Debug mode - visualize safe areas */.debug-safe-areas::before {  content: '';  position: fixed;  top: 0;  left: 0;  right: 0;  height: env(safe-area-inset-top);  background: rgba(255, 0, 0, 0.3);  z-index: 9999;  pointer-events: none;}
.debug-safe-areas::after {  content: '';  position: fixed;  bottom: 0;  left: 0;  right: 0;  height: env(safe-area-inset-bottom);  background: rgba(0, 0, 255, 0.3);  z-index: 9999;  pointer-events: none;}

Resources

Source and attribution

Source:cap-go/capgo-skillsinskills/safe-area-handlingat commit2cfb277

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal