A comprehensive guide to building fullscreen web games and PWAs on iPhone, covering safe area insets, Dynamic Island handling, canvas sizing, and the many undocumented gotchas that will waste your time if you don't know about them.
Building a fullscreen web game for iPhone remains one of the most technically frustrating challenges in web development. The core difficulty stems from Apple's layered physical constraints — the Dynamic Island, notch, home indicator, rounded corners — combined with iOS Safari's incomplete web standards support (no Fullscreen API, no orientation lock) and persistent WebKit bugs. This guide covers the foundational patterns, exact pixel values, critical gotchas, and hard-won workarounds.
The entire safe-area system for web content hinges on a single meta tag. Without viewport-fit=cover in the viewport declaration, all env(safe-area-inset-*) values resolve to 0px and Safari letterboxes your content with blank bars in landscape.
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">With viewport-fit=cover active, your content extends edge-to-edge behind the notch, Dynamic Island, rounded corners, and home indicator. You then use four CSS environment variables — env(safe-area-inset-top), env(safe-area-inset-right), env(safe-area-inset-bottom), env(safe-area-inset-left) — to inset interactive UI elements.
The critical design pattern for canvas games is full-bleed rendering with offset HUD: let your game world fill the entire screen (including behind the cutout), but keep all interactive elements — buttons, score, health bars, controls — within the safe area.
This is the single most insidious gotcha for fullscreen PWA games on iOS. There are three ways to set full-height on html/body, and only one works correctly:
html, body {
margin: 0;
padding: 0;
overflow: hidden;
/* ❌ BROKEN: 100% breaks viewport-fit=cover + black-translucent entirely.
Content never extends behind the notch/Dynamic Island. */
/* height: 100%; */
/* ❌ BROKEN: 100dvh reports wrong values on PWA cold start.
Only corrects itself after rotating portrait → landscape → portrait. */
/* height: 100dvh; */
/* ✅ CORRECT: 100vh is the ONLY value that works from cold start. */
height: 100vh;
}Why each fails:
-
height: 100%— The percentage-based height calculation does not account for the area behind the status bar/notch/Dynamic Island whenviewport-fit=coverandblack-translucentare active. Safari silently falls back to the default (non-cover) layout, defeating the entire purpose ofviewport-fit=cover. Everything looks correct in your CSS but the layout collapses. -
height: 100dvh— The dynamic viewport unit isn't properly initialized on PWA cold start. The layout engine hasn't computed the dynamic viewport values until the viewport has been "exercised" through a geometry change (i.e., rotating the device). You cannot programmatically trigger this recalculation. -
height: 100vh— Works correctly from cold start. In PWA standalone mode there is no toolbar to create the classic100vhvs100dvhdiscrepancy, so100vh,100dvh,window.innerHeight, andscreen.heightall converge to the same value once the viewport has settled. Use100vhbecause it's the only one that's correct from the start.
Note: In Safari (non-standalone), 100vh includes the area behind the toolbar, making it taller than the visible viewport. This is the well-known 100vh bug. The 100dvh unit was designed to fix this, but since it breaks on PWA cold start, you're stuck with 100vh regardless. For Safari-hosted games, window.innerHeight is the most reliable dimension source for canvas sizing.
For canvas-based games, env() values aren't directly accessible from JavaScript. The commonly recommended CSS custom property bridge has a known WebKit bug (#274773) that can return stale or zero values (see also WebKit bug #191872 for delayed initialization of env() values). A more reliable approach is to create physical DOM probe elements:
// Measure a CSS env() value by creating a probe element
function measureEnv(prop) {
const el = document.createElement('div');
el.style.cssText = `position:fixed;top:0;left:0;width:0;` +
`height:env(${prop}, 0px);visibility:hidden;pointer-events:none`;
document.body.appendChild(el);
const val = el.offsetHeight;
el.remove();
return val;
}
// Usage
const insets = {
top: measureEnv('safe-area-inset-top'),
bottom: measureEnv('safe-area-inset-bottom'),
left: measureEnv('safe-area-inset-left'),
right: measureEnv('safe-area-inset-right'),
};This works because offsetHeight forces a synchronous layout calculation, giving you the browser's actual resolved value rather than a potentially stale CSS variable. Creating and destroying the probe each time ensures a fresh measurement.
The env() inset values themselves can be wrong on PWA cold start — the same timing issue that affects 100dvh. The workaround is a multi-strategy probing approach:
function update(reason) {
const envTop = measureEnv('safe-area-inset-top');
const envBottom = measureEnv('safe-area-inset-bottom');
const envLeft = measureEnv('safe-area-inset-left');
const envRight = measureEnv('safe-area-inset-right');
// Apply insets to your canvas/HUD layout...
}
// Initial update
update('init');
// Re-check after delays (env() may populate late)
setTimeout(() => update('timeout-100ms'), 100);
setTimeout(() => update('timeout-500ms'), 500);
setTimeout(() => update('timeout-1s'), 1000);
// Workaround: toggle viewport-fit to force env() recalculation
if (window.navigator.standalone) {
const meta = document.querySelector('meta[name="viewport"]');
const original = meta.getAttribute('content');
// Briefly switch to viewport-fit=auto then back to cover
meta.setAttribute('content',
original.replace('viewport-fit=cover', 'viewport-fit=auto'));
requestAnimationFrame(() => {
meta.setAttribute('content', original);
requestAnimationFrame(() => update('viewport-toggle'));
});
}
// Also re-probe on resize and orientation change
window.addEventListener('resize', () => update('resize'));
window.addEventListener('orientationchange', () =>
setTimeout(() => update('orientationchange'), 300)
);The viewport-fit toggle trick is the key innovation here — briefly switching to viewport-fit=auto and back to cover forces WebKit to recalculate the env() values without requiring the user to physically rotate the device. The requestAnimationFrame calls give the layout engine a tick to process each change. The staggered timeouts provide belt-and-suspenders coverage because no single moment is reliably "the right one" on iOS.
| iPhone Category | Models | Portrait Insets (T / B / L / R) |
|---|---|---|
| No notch | SE (all gens), 6S–8 series | 20 / 0 / 0 / 0 |
| Notch (gen 1) | X, XS, 11 Pro | 44 / 34 / 0 / 0 |
| Notch (gen 2) | 12–14, 14 Plus | 47 / 34 / 0 / 0 |
| Dynamic Island | 14 Pro – 16, 16 Plus | 59 / 34 / 0 / 0 |
| Dynamic Island (large) | 16 Pro, 16 Pro Max | 62 / 34 / 0 / 0 |
The top inset in portrait covers the status bar area (20pt on non-notch devices) or the notch/Dynamic Island region on newer models. The bottom inset of 34pt on Face ID models accounts for the home indicator.
| iPhone Category | Models | Landscape Insets (T / B / L / R) |
|---|---|---|
| No notch | SE (all gens), 6S–8 series | 0 / 0 / 0 / 0 |
| Notch (gen 1) | X, XS, 11 Pro | 0 / 21 / 44 / 44 |
| Notch (gen 2) | 12–14, 14 Plus | 0 / 21 / 47 / 47 |
| Dynamic Island | 14 Pro – 16, 16 Plus | 0 / 21 / 59 / 59 |
| Dynamic Island (large) | 16 Pro, 16 Pro Max | 0 / 21 / 62 / 62 |
The bottom inset in landscape accounts for the home indicator gesture area. The left/right insets cover the notch or Dynamic Island, which moves to the side in landscape. iOS applies symmetric insets — both left and right return identical values regardless of which side the cutout is on.
Recent iOS updates have introduced a touch dead zone along the top edge in landscape that is NOT reported by env(safe-area-inset-top) — the value still returns 0px. This was initially expected only on iPhone 17 models, but has been observed on at least iPhone 15 running current iOS. The dead zone exists — touches in that region are silently captured by the OS — but the web platform provides no information about its size. There is no visual indication; buttons placed in this region simply don't fire.
Do not place interactive elements flush against the top edge in landscape, even though safe-area-inset-top reports 0px. A conservative buffer of at least 20px from the top is recommended until Apple exposes this inset properly.
This may be related to iOS 18's touch rejection algorithm changes or an intentional system gesture reservation being rolled out to older hardware via software updates.
From a web developer's perspective, the Dynamic Island and notch are handled identically through env(safe-area-inset-*) — there is no separate API to distinguish between them. The only difference is the magnitude of the inset values. Dynamic Island models report 59–62pt side insets in landscape versus 44–50pt for notch models, roughly a 30% increase in reserved space despite the Dynamic Island being physically smaller than the notch.
The Dynamic Island can also expand dynamically for Live Activities, phone calls, timers, and navigation. An expanded Dynamic Island occupies significantly more screen real estate than its compact form. Web developers cannot control or detect this expansion — the env() values remain fixed at the compact-state safe area. Test with active Live Activities (e.g., a running timer) to verify your layout handles the expanded state gracefully.
These are fundamental platform restrictions that affect all web games on iOS, regardless of orientation.
element.requestFullscreen() works on iPad but is completely unsupported on iPhone. The only path to removing browser chrome is running as a PWA in standalone mode via "display": "standalone" in the web app manifest. Note that "display": "fullscreen" silently falls back to standalone on iOS.
The Screen Orientation API (screen.orientation.lock()) is unsupported on all iOS browsers and PWAs. The manifest "orientation": "landscape" field is ignored on iOS (include it for Android compatibility). Detect unwanted orientation via media queries and either display a "rotate your device" prompt or apply a CSS transform.
The home indicator bar auto-hides after a few seconds of inactivity in PWA standalone mode, similar to how native apps behave with prefersHomeIndicatorAutoHidden. However, the bottom safe area inset persists regardless — iOS still reserves the gesture region even when the indicator line itself fades. Keep interactive elements clear of that zone.
The Badging API (navigator.setAppBadge()) reports as available and the call resolves without error, but the badge number never actually appears on the PWA's home screen icon. Because the set operation silently fails, navigator.clearAppBadge() is also untestable. Do not rely on badge counts for any game functionality.
| Behavior | Safari | PWA Standalone |
|---|---|---|
| Browser chrome | Address bar + toolbar visible | None — maximum screen space |
| Edge swipe gestures | Back/forward navigation | Disabled (unless navigation history exists) |
| Viewport height | Dynamic (toolbar show/hide changes it) | Fixed and predictable |
| Status bar | Managed by Safari | Always visible; use black-translucent to overlay |
| Storage | Shared with Safari | Isolated — one-time cookie copy at install, then fully separate |
| Background survival | Tab may be suspended | Killed and restarted from scratch |
| External links | Stay in browser | Open Safari View Controller (breaks immersion) |
| Height units | 100vh ≠ 100dvh (toolbar difference) |
100vh = 100dvh = window.innerHeight |
For games, PWA standalone mode is strongly preferred despite its limitations. The stable viewport dimensions and reduced browser chrome justify the tradeoffs.
A note on PWA storage isolation: When a PWA is installed to the home screen, iOS performs a one-time copy of Safari's cookies into the PWA's isolated storage context. This means if you're logged into a site in Safari, you'll be automatically logged in when you first launch the PWA. After that initial copy, the two contexts diverge completely — cookies, localStorage, and IndexedDB are fully independent. Logging out in Safari won't log you out of the PWA, and vice versa.
Essential PWA manifest:
{
"display": "standalone",
"orientation": "landscape",
"start_url": "/?standalone=true"
}Note: "orientation": "landscape" is ignored by iOS but should be included for Android compatibility.
In Safari, swiping from the left or right screen edge triggers back/forward navigation. In PWA standalone mode, these gestures are disabled only when there is no navigation history. If your PWA navigates to another page (actual page navigation, not SPA hash/route changes), the left-edge back swipe reactivates. Single-page architecture is strongly recommended for games — keep everything on one page and manage state transitions in JavaScript. For Safari-hosted games, intercept touchstart events near screen edges with preventDefault() (must use { passive: false }).
iOS aggressively discards PWA memory when users switch apps. Your game will restart from scratch when the user returns. Persist critical state (level, score, settings) to localStorage or IndexedDB continuously. Design your game to handle cold restarts gracefully.
After rotating, window.innerWidth and window.innerHeight may report stale values through multiple intermediate resize events. On iOS 17+, the rotation animation takes longer, requiring 500ms+ delays before reading final dimensions:
window.addEventListener('orientationchange', () => {
const check = setInterval(() => {
const w = window.innerWidth, h = window.innerHeight;
if ((w > h) === expectingLandscape) {
clearInterval(check);
resizeCanvas(w, h);
}
}, 100);
setTimeout(() => clearInterval(check), 2000);
});iOS Safari's 100vh includes the area behind the browser toolbar, extending content below the visible viewport. This only affects Safari (not PWA standalone mode, where 100vh is correct). For Safari-hosted games, window.innerHeight is the most reliable dimension source for canvas sizing. 100dvh (dynamic viewport height, available iOS 15.4+) was designed to fix this but has the cold-start bug described in Section 2.
Multiple developers report env(safe-area-inset-*) returning 0px in Safari portrait mode (because Safari's chrome already covers the notch area). In landscape, clicking "Hide Toolbar" in Safari can also reset values. iOS 26.1 introduced a temporary regression where safe-area-inset-top incorrectly returned 20pt in landscape; this was fixed in iOS 26.2 beta 3. See Section 3 for the probing workaround.
A confirmed WebKit bug causes memory usage to increase with each canvas resize until the browser tab crashes at ~1.25GB. Minimize resize frequency during orientation changes and never recreate WebGL contexts — reuse existing ones and update dimensions only.
All canvases on a page share a combined 256MB memory limit. At devicePixelRatio of 3, a full-resolution canvas for iPhone 16 Pro Max (1320×2868 actual pixels) consumes significant memory. Consider capping your render scale at 2× rather than native 3× for performance and memory headroom.
After orientation changes, clientX/clientY from touch events can reference stale coordinate spaces. Moving touches (drags) are especially affected. Always compute touch position relative to canvas.getBoundingClientRect() rather than using raw screen coordinates.
Phaser's Scale.FIT mode incorrectly calculates parent height with viewport-fit=cover, creating gaps. The community consensus is to bypass Phaser's automatic scaling and use window.innerHeight directly for manual canvas sizing on iOS.
Game world renders edge-to-edge; UI elements are offset by safe area values. Maximizes immersion while keeping controls accessible. Most native App Store games use this approach.
canvas {
position: fixed;
top: 0; left: 0;
width: 100vw;
height: 100vh;
}
.hud {
position: fixed;
inset: 0;
pointer-events: none;
padding:
max(20px, env(safe-area-inset-top, 0px))
max(16px, env(safe-area-inset-right, 0px))
max(8px, env(safe-area-inset-bottom, 0px))
max(16px, env(safe-area-inset-left, 0px));
}
html, body {
margin: 0;
padding: 0;
overflow: hidden;
background: #000; /* blends with Dynamic Island */
overscroll-behavior: none;
-webkit-user-select: none;
touch-action: none;
height: 100vh; /* NOT 100% or 100dvh — see Section 2 */
}The max() function ensures minimum padding on no-notch devices while respecting the full safe area on notched and Dynamic Island models. The 20px top minimum accounts for the phantom top dead zone (see Section 4). The black background makes the Dynamic Island cutout invisible against the bezel.
Canvas itself is constrained to the safe rectangle, with black borders filling unsafe zones. Simpler to implement but wastes 10–15% of screen width on Dynamic Island models in landscape.
Apply the larger inset value to both sides for visual symmetry:
padding-left: max(env(safe-area-inset-left), env(safe-area-inset-right));
padding-right: max(env(safe-area-inset-left), env(safe-area-inset-right));Design your layout to fit within a centered rectangle covering 90% of screen width and height. This single rule handles notches, Dynamic Islands, rounded corners, and the home indicator across all models without model-specific logic.
Rather than merely avoiding the Dynamic Island, some developers have embraced it. "Hit The Island" by Kriss Smolka is a Pong-style game where the Dynamic Island serves as the target. "Pixel Pals" places virtual pet characters inside the Dynamic Island via Live Activities.
Apple's WWDC24 Session 10085 ("Design advanced games for Apple platforms") recommends the full-bleed-with-offset-HUD pattern and introduced concepts around adaptive layouts that respond to different device configurations.
iOS 16 introduced safeAreaAspectFitLayoutGuide for native apps — a layout guide specifically for fullscreen games with fixed aspect ratios that computes an optimal content rectangle while avoiding all hardware obstructions. While this is a native UIKit API unavailable to web content, the concept can be replicated in JavaScript for canvas games.
The recommended minimum physical device testing matrix:
- iPhone SE — No notch, no safe area insets (validates graceful degradation)
- iPhone 14 — Notch, 47pt landscape side insets
- iPhone 15 or 16 Pro — Dynamic Island, 59–62pt landscape side insets
Simulators do not reliably reproduce safe-area behavior. Physical device testing is non-negotiable.
Key testing scenarios:
- Cold start — Install the PWA, force-quit it, then launch. Verify safe area insets are correct immediately, not just after interaction.
- Orientation changes — Rotate between portrait and landscape multiple times. Verify canvas sizing and touch coordinates remain correct.
- Active Live Activities — Run a timer or navigation session to test with an expanded Dynamic Island.
- Background/foreground — Switch away from the PWA and return. Verify state persistence and layout correctness.
- Top edge touches in landscape — Place test buttons flush against the top edge to verify the phantom dead zone on your target devices.
- WebKit Blog: Designing Websites for iPhone X — The original reference for
viewport-fit=coverandenv()variables - Ben Frain: CSS Environment Variables — Practical implementation guide
- Ben Frain: Getting env() Values in JavaScript — CSS-to-JS bridge technique
- Ruoyu Sun: Full Screen WebView and Notch Guide — Comprehensive notch handling for web views
- Use Your Loaf: iPhone Screen Sizes — Exact dimensions and inset values per model
- web.dev: PWA App Design — PWA display modes and fullscreen behavior
- MDN: env() CSS Function — Specification reference
- Apple Developer Forums: Safe Area Insets — Native perspective on inset values
- WWDC24 Session 10085: Design Advanced Games — Apple's game UI design guidance
- PWA Test Tool — iOS PWA testing and safe area inset debugging
Amazing ressource!