--- name: safe-area-handling description: 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. --- # 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 | Inset | Description | |-------|-------------| | `safe-area-inset-top` | Notch/Dynamic Island/status bar | | `safe-area-inset-bottom` | Home indicator/navigation bar | | `safe-area-inset-left` | Left edge (landscape) | | `safe-area-inset-right` | Right edge (landscape) | ## CSS Solution ### Enable Viewport Coverage ```html ``` **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 properties function 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 change window.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({ 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); } updateInsets(); window.addEventListener('resize', updateInsets); window.addEventListener('orientationchange', () => { setTimeout(updateInsets, 100); }); return () => { window.removeEventListener('resize', updateInsets); }; }, []); return insets; } // Usage function Header() { const { top } = useSafeArea(); return (
App 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.ts import 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.swift import UIKit import Capacitor @UIApplicationMain class 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 UIViewControllerBasedStatusBarAppearance UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight ``` ## Native Android Configuration ### Display Cutout Mode ```xml ``` ### Edge-to-Edge Display ```kotlin // android/app/src/main/java/.../MainActivity.kt import android.os.Build import android.view.View import android.view.WindowInsets import 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 ``` ## Capacitor Status Bar Plugin ### Installation ```bash bun add @capacitor/status-bar bunx cap sync ``` ### Usage ```typescript import { StatusBar, Style } from '@capacitor/status-bar'; // Set status bar style await StatusBar.setStyle({ style: Style.Dark }); // Set background color (Android) await StatusBar.setBackgroundColor({ color: '#ffffff' }); // Show/hide status bar await StatusBar.hide(); await StatusBar.show(); // Overlay mode await StatusBar.setOverlaysWebView({ overlay: true }); ``` ## Common Issues and Solutions ### Issue: Content Behind Notch **Solution**: Add viewport-fit and safe area padding ```html ``` ```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 ``` ## 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 - Apple Human Interface Guidelines: https://developer.apple.com/design/human-interface-guidelines/layout - Android Display Cutouts: https://developer.android.com/develop/ui/views/layout/display-cutout - CSS env() specification: https://drafts.csswg.org/css-env-1/ - Capacitor Status Bar: https://capacitorjs.com/docs/apis/status-bar