joel.design/system← Back to site
Foundations / iOS Hardening

DevTools mobile emulation lies.

Eight rules locked after a real-device audit. Every one was a bug I shipped and then fixed. They’re here because mobile is ~95% of my traffic and Chrome’s mobile mode renders nothing like a real iPhone Safari.

Why this is a foundation
Most design systems treat browser quirks as bugs to fix. I treat the iOS Safari gap as a foundational design constraint. You can’t ship a brand to mobile if the brand renders blank. Every rule here is non-negotiable for any new joel.design surface.

01. No universal font-size-adjust.

Problem. iOS Safari handling of font-size-adjust on flex children with white-space: nowrap and overflow: hidden causes text to render invisibly. I hit this on .lab-item titles. Dashed-border boxes were empty on real iPhones, full text in DevTools.

Rule. Never use * { font-size-adjust: ... }. Bump html font-size instead (I use 18px) to compensate for Calibre's smaller x-height.

html { font-size: 18px }

02. 100vh always paired with 100svh.

Problem. iOS Safari includes the URL bar in vh, so a hero set to 100vh fits the screen for one render. Then the URL bar collapses on scroll, the viewport gains pixels, and the hero suddenly has empty space below.

Rule. Every viewport-height declaration includes a 100svh second line. Older browsers fall back to vh, modern iOS uses svh (small viewport height, ignores the address bar).

min-height: 100vh;
min-height: 100svh;

03. Modern CSS gated in @supports.

Problem. text-wrap: pretty/balance, font-optical-sizing, and font-size-adjust were added to iOS Safari only in 17.4. Before that they're ignored. Mostly harmless, occasionally cause subtle layout differences vs. the designed wrap.

Rule. Wrap optional typography enhancements in @supports queries so unsupported browsers default to the browser's native behavior.

@supports (text-wrap: balance) {
  h1, h2, h3 { text-wrap: balance; }
}

04. overflow-x: hidden on body, never clip.

Problem. iOS Safari < 16 doesn't recognize overflow-x: clip and falls back to visible. Any rogue element wider than the viewport then creates a horizontal scroll.

Rule. Body uses overflow-x: hidden, which is universally supported and creates a scroll context that prevents horizontal overflow.

body { overflow-x: hidden; }

05. Every backdrop-filter paired with -webkit-backdrop-filter.

Problem. Without the prefix, blurs fail silently on iOS Safari. Glassmorphism UI degrades to opaque backdrop with no warning.

Rule. Every backdrop-filter declaration has a -webkit-backdrop-filter twin, declared first.

-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);

06. Defensive translateZ(0) on overflow:hidden parents.

Problem. iOS Safari's overflow:hidden plus animated transform child bug causes children to render invisibly. I hit this twice. First with .vhs-noise inside .archive-link, then with .lab-item inside .lab-callout.

Rule. Any overflow: hidden parent that contains an animated transform/keyframe child gets transform: translateZ(0) to force its own compositing layer.

transform: translateZ(0); /* iOS clipping fix */

07. .reveal is opacity: 1 by default.

Problem. Real iOS Safari throttles setTimeout fallbacks during initial load. Any pattern where content is opacity: 0 by default and depends on JS to reveal will show a blank page if the JS gets throttled. Which it will, on real devices.

Rule. .reveal opacity defaults to 1. JS opts INTO the entrance animation by adding html.reveals-armed to the document. If JS fails or stalls, content stays visible.

.reveal { opacity: 1; }
html.reveals-armed .reveal { opacity: 0; transform: translateY(20px); }

08. will-change only on actively animated elements.

Problem. Each will-change promotes an element to its own compositing layer. Too many means GPU memory pressure on iOS, which means jank, flicker, or animations dropping entirely.

Rule. will-change only on elements that are actively animating right now. Remove it from speculative 'might animate sometime' usage.

.archive-link .vhs-noise { will-change: background-position; } /* always animating */

How to test.

DevTools mobile mode runs the desktop rendering engine at a smaller viewport. It does not reproduce iOS-specific bugs. The only reliable test is a real iPhone (or a Mac with Safari opened to the iPhone simulator).

Workflow: enable Safari Web Inspector on iPhone (Settings Safari Advanced Web Inspector ON), connect to Mac via cable, open the Safari Develop menu on Mac, select the iPhone, debug live. I run this loop for every new component and every visual change before merge.