Skip to content

Instantly share code, notes, and snippets.

@fozzedout
Last active July 20, 2026 16:52
Show Gist options
  • Select an option

  • Save fozzedout/5e77925381991a9570151550992baf14 to your computer and use it in GitHub Desktop.

Select an option

Save fozzedout/5e77925381991a9570151550992baf14 to your computer and use it in GitHub Desktop.

iOS Game UI for Web & PWA: A Developer Guide

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.


1. The Foundational CSS Pattern

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.


2. The Height Declaration Trap

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 when viewport-fit=cover and black-translucent are active. Safari silently falls back to the default (non-cover) layout, defeating the entire purpose of viewport-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 classic 100vh vs 100dvh discrepancy, so 100vh, 100dvh, window.innerHeight, and screen.height all converge to the same value once the viewport has settled. Use 100vh because 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.


3. Reading Safe Area Insets in JavaScript

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 Cold-Start Probing Problem

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.


4. Safe Area Insets by Device and Orientation

Portrait Insets (CSS Points)

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.

Landscape Insets (CSS Points)

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.

The Phantom Top Dead Zone in Landscape (⚠️ Undocumented)

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.


5. Dynamic Island vs Notch

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.


6. iOS Platform Limitations for Web Games

These are fundamental platform restrictions that affect all web games on iOS, regardless of orientation.

No Fullscreen API on iPhone

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.

No Orientation Lock

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.

Home Indicator Behavior

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.

Badge API Silently Fails

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.


7. Safari vs PWA Standalone Mode

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 100vh100dvh (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.


8. Critical Gotchas

Edge Swipe Navigation

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 }).

PWAs Are Killed on Background

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.

Orientation Change Reports Wrong Dimensions

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);
});

The 100vh Problem in Safari

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.

env() Values Can Return 0px Incorrectly

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.

WebGL Canvas Resizing Leaks Memory

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.

Canvas Memory Capped at 256MB

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.

Touch Coordinates Misalign After Rotation

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 and Framework Scale Managers

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.


9. Design Strategies

Full-Bleed with Inset HUD (Recommended)

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.

Safe-Area Letterbox

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.

Balanced Symmetric Insets

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));

The 90% Rule

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.


10. Creative Approaches

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.


11. Testing

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.

12. Key Resources

@wwwonka

wwwonka commented Jun 19, 2026

Copy link
Copy Markdown

Amazing ressource!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment