Loading...
Loading...
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.
npx skill4agent add cap-go/capgo-skills safe-area-handling| Inset | Description |
|---|---|
| Notch/Dynamic Island/status bar |
| Home indicator/navigation bar |
| Left edge (landscape) |
| Right edge (landscape) |
<!-- index.html -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>viewport-fit=cover/* 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);
}/* 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 {
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 */
}.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%;
}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);
});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);
}
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 (
<header style={{ paddingTop: top }}>
App Header
</header>
);
}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;
}// capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
ios: {
// Content extends behind status bar
contentInset: 'automatic', // or 'always', 'scrollableAxes', 'never'
},
};// 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
}
}<!-- 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><!-- 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>// 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
)
}
}
}<!-- 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>bun add @capacitor/status-bar
bunx cap syncimport { 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 });<meta name="viewport" content="viewport-fit=cover">body {
padding-top: env(safe-area-inset-top);
}.tab-bar {
padding-bottom: env(safe-area-inset-bottom);
}.content {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}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)';
});<!-- Must be exactly like this -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>/* 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;
}