plugins/capacitor-ui/skills/safe-area-handling/SKILL.md
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 skillsauth add cap-go/capgo-skills safe-area-handlingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Handle iPhone notch, Dynamic Island, home indicator, and Android cutouts properly.
Safe areas are the regions of the screen not obscured by:
| 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) |
<!-- 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.
/* 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);
}
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;
}
// 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>
npm install @capacitor/status-bar
npx cap sync
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 });
Solution: Add viewport-fit and safe area padding
<meta name="viewport" content="viewport-fit=cover">
body {
padding-top: env(safe-area-inset-top);
}
Solution: Add bottom safe area padding
.tab-bar {
padding-bottom: env(safe-area-inset-bottom);
}
Solution: Handle left/right insets
.content {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
Solution: Use adjustResize and handle insets dynamically
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)';
});
Cause: Missing viewport-fit=cover
Solution:
<!-- 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;
}
development
Guide for migrating an existing web app, PWA, or SPA into a store-ready Capacitor iOS and Android app. Use this skill when users want to wrap or convert a web app into a mobile app, avoid thin WebView app store rejection, add native-feeling UX, handle permissions, offline behavior, account deletion, billing, testing, and Capgo live updates.
development
Guide to using Tailwind CSS in Capacitor mobile apps. Covers mobile-first design, touch targets, safe areas, dark mode, and performance optimization. Use this skill when users want to style Capacitor apps with Tailwind.
development
Revenue playbook for getting a mobile or web subscription app from zero to early MRR. Use when users ask how to make revenue, reach $1K MRR, monetize an app, get first users, improve ASO, plan TikTok/Reels/Shorts or Reddit acquisition, design a paywall, choose freemium vs trial, price subscriptions, reduce churn, or build a simple growth loop for an app.
tools
Guides the agent through migrating SQLite and SQL-style Capacitor plugins to @capgo/capacitor-fast-sql. Use when replacing bridge-based SQL plugins, adding encryption, preserving transactions, or moving key-value storage onto Fast SQL. Do not use for non-SQL storage, generic app upgrades, or plugins that already wrap Fast SQL.