Insight 8 min read

The 34 Pixels You Cannot Have: WebKit's Home Indicator Zone and the iOS PWA Layout Ceiling

On every Face ID iPhone, a 34px zone at the bottom belongs to WebKit and no CSS will reach it. Here's the full investigation and what actually works.

When you install a web app on an iPhone home screen (what Apple calls a Progressive Web App in standalone mode), you expect it to behave like a native app. It launches full screen, hides the browser chrome, fills the display edge to edge. In 2026, on a modern iPhone, that expectation is almost entirely correct. Almost.

There is a 34-pixel zone at the bottom of every Face ID iPhone that a PWA cannot paint into. Not with position: fixed. Not with transform. Not with overflow: visible. Not with any CSS escape route. The home indicator zone, the region occupied by the swipe gesture bar, belongs to WebKit, and web content is not allowed there.

This post documents the full investigation: why it exists, what the spec says, and what actually works.

Background: The iPhone X Safe Area Problem

When Apple shipped the iPhone X in 2017, it introduced a non-rectangular display for the first time: a notch at the top for Face ID hardware, and a rounded corner radius that softened the screen edges. The bottom had no physical button anymore. A horizontal swipe gesture replaced the home button, indicated by a small pill-shaped bar.

Web developers suddenly had a problem. Their full-screen content was being obscured by the notch and the bottom gesture bar. Apple’s answer was a new CSS mechanism, proposed through WebKit and later standardized by the W3C: viewport-fit=cover and the env(safe-area-inset-*) family of CSS environment variables.

The original announcement came in a WebKit engineering blog post on September 22, 2017:

“Content is automatically inset within the display’s safe area so it is not obscured by the rounded corners, or the device’s sensor housing. The inset area is filled with the page’s background-color (as specified on the <body> or <html> elements).”

— Timothy Horton & Elika Etemad (fantasai), Designing Websites for iPhone X, webkit.org/blog/7929/designing-websites-for-iphone-x/

That sentence, “the inset area is filled with the page’s background-color,” is the key. It’s not filled by the element you paint there. It’s filled by html or body. Not a bug. Documented, intended behavior.

The four safe-area-inset-* variables are now formally defined in the W3C CSS Environment Variables Module Level 1 working draft, and the viewport-fit descriptor originates in the CSS Round Display Level 1 spec, originally designed for circular smartwatch displays and then adapted by Apple for notched phones.

The Pantri Investigation

Pantri is a household management PWA: shopping lists, pantry inventory, recipes, meal planning. On iPhone, it’s installed to the home screen and used in standalone mode. The bottom navigation bar is a position: fixed; bottom: 0 element with five icon + label tabs.

On first launch after installation, a dark strip was visible below the navigation bar on iPhone 12 Max. The nav appeared to stop short of the screen’s physical bottom.

The Coordinate System

The iPhone 12 Max display is 926 physical pixels tall. In iOS standalone mode, the layout viewport is 879px (47px are consumed by the status bar at the top). The env(safe-area-inset-bottom) value is 34px, which maps to the home indicator zone.

Physical screen (926px)
┌──────────────────┐  y=0
│  Status bar 47px │       ← html/body background fills here
├──────────────────┤  y=47 ← layout viewport top
│                  │
│  App content     │       ← window.innerHeight = 879px
│                  │
│  Nav icon area   │  y=836 (physical)
│  56px            │
│  ┈┈┈┈┈┈┈┈┈┈┈┈┈┈ │  y=892 (physical)
│  Home indicator  │       ← safe-area-inset-bottom zone
│  zone 34px       │       ← html/body background only
└──────────────────┘  y=926 (physical) ← layout viewport bottom

The key thing I confirmed early: position: fixed; bottom: 0 does anchor at layout y=879 (physical y=926). I tested this with a measurement probe, a position: fixed; bottom: 0; height: 1px element whose getBoundingClientRect().bottom read 879. The element’s layout box reaches the physical screen bottom. But visual rendering stops at physical y=892. The home indicator zone (physical y=892 to y=926) does not accept painted content.

Seven Attempts

I tried seven distinct approaches before accepting that this wasn’t solvable with CSS.

AttemptApproachResult
1h-screen, h-full (CSS viewport units)Gap — all resolve to 845px (safe-area viewport)
2100dvh, 100svhSame — all viewport unit variants resolve to 845px on iOS standalone
3window.innerHeight via CSS custom propertyLayout gap = 0px (correct math), visual strip persists
4position: fixed overlay painting the indicator zoneOverlay in correct position, strip persists
5overflow: hidden removal on html/bodyStrip persists
6bottom: calc(-1 * env(safe-area-inset-bottom)) on navNav shifts down, strip persists
7transform: translateY(env(safe-area-inset-bottom)) on navNav shifts down, strip persists

Attempts 1 and 2 revealed the first important fact: CSS viewport units on iOS are not the same as window.innerHeight. The W3C CSS Values and Units Module Level 4 spec defines vh as tied to the initial containing block, which on iOS resolves to the large viewport. Safari 15.4 shipped svh/dvh/lvh, but in standalone mode all three variants resolve identically and none of them match window.innerHeight. The JS measurement (window.innerHeight = 879) correctly reflects the layout viewport; the CSS units do not. WebKit Bug #242758 and #261185 document related regressions.

Attempt 3 fixed the math: setting a --app-height CSS custom property from window.innerHeight on startup and using it as the layout container height eliminates any CSS-unit-based gap. Diagnostics confirmed: gap (innerH - navBtm) = 0px. But the strip persisted. The problem wasn’t a layout gap. The layout was right. WebKit just wasn’t painting elements into that zone.

Attempts 4 through 7 ruled out every remaining CSS angle: overlay elements, overflow settings, negative bottom offsets, transform shifts. None of them reached physical y=892-926.

The test that settled it was setting a red html/body background. The strip turned red. That ruled out an OS compositor overlay (which would be unaffected by CSS) and confirmed the strip was showing html/body’s background color. The 2017 WebKit post had described exactly this behavior, and it was still true eight years later.

Why WebKit Does This

This is an intentional platform constraint, not a CSS bug.

The home indicator zone is reserved for system-level gesture recognition, specifically the swipe-up-to-go-home gesture. Apple’s UIKit layer needs exclusive control over it. In a native app, you get UIViewController.preferredScreenEdgesDeferringSystemGestures and UIViewController.prefersHomeIndicatorAutoHidden to control how the OS handles this region. Both are UIKit-only. There’s no web equivalent.

A standalone PWA has no access to UIKit, no host application, no native wrapper. WebKit enforces the safe area constraint uniformly. The home indicator zone gets composited at the OS level and filled from html/body background, and there’s no web API to change that.

This is also why viewport-fit=cover doesn’t help. What it does is unlock env(safe-area-inset-*) values and let position: fixed extend its layout box to the physical screen edge. What it doesn’t do is let web content paint into the zone the OS has reserved. The layout can reach y=926. Element content can’t render there. The OS compositor intercepts that region and forces the underlying page background to peek through, bypassing whatever the fixed element specifies. That’s why the red background test worked the way it did: if it were a true rendering block, the strip would have stayed whatever color sits behind the compositor. It’s not blocked. It’s intercepted.

One more thing: standalone mode and regular Safari are not the same environment. In standard mobile Safari, the browser chrome physically occupies screen space and the viewport just doesn’t extend that far. In standalone mode, iOS gives the app a full-screen window and the layout viewport reaches the physical bottom, but WebKit’s safe area compositing still applies. All viewport unit variants (vh, dvh, svh, lvh) resolve to the safe-area-adjusted height rather than the full window.innerHeight. The two environments look similar on the surface, which is why workarounds that seem to work in Safari can still fail in standalone mode.

The Fix

The right approach is what native iOS apps do: match colors instead of fighting the constraint.

// Bottom nav
<nav
  className="fixed bottom-0 left-0 right-0"
  style={{ paddingBottom: 'env(safe-area-inset-bottom, 34px)' }}
>
  {/* Nav icons sit in the 56px content area above the indicator zone */}
</nav>

// Scrollable content — must clear the full nav height
<main style={{ paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 34px))' }}>

The nav’s paddingBottom pushes the icon content above the home indicator zone. The 34px padding area fills with the nav’s background color (#080f15). The html/body is also #080f15. They match. The strip is still there (WebKit is still rendering it from html/body) but it’s invisible because the colors are the same. This is exactly what the 2017 WebKit blog post described as the intended behavior.

For this to work, html/body needs to carry the same background color as your nav. If they differ, the strip will be visible and will show the page background. That’s a real design constraint: your bottom nav surface and your page background need to match.

The Native Wrapper Alternative

The only way to actually render web content into the home indicator zone is to wrap the web view in a native host application like Capacitor or Cordova. A native Capacitor app can set prefersHomeIndicatorAutoHidden and preferredScreenEdgesDeferringSystemGestures on the host UIViewController, which changes how the OS composites the indicator. The web content still runs in WKWebView, but the native host has overridden the indicator behavior.

The trade-off is significant: you’re now maintaining a native Xcode project, signing and distributing through the App Store, and giving up the zero-install web distribution model. For a household tool where the target install base is two or three people, that doesn’t make sense. The color-match approach gets you the same visual result for zero additional complexity.

Diagnostic Infrastructure

One useful thing to come out of this investigation: Pantri now ships a diagnostics panel (Settings > Diagnostics) that measures the relevant geometry live on-device. It shows window.innerHeight, window.screen.height, safe-area-inset-top, safe-area-inset-bottom, the nav’s getBoundingClientRect() values, and a computed gap metric. There are also CSS experiment toggles that let you test layout hypotheses by toggling HTML classes via localStorage, no new deploy required.

That pattern turned out to be significantly more useful than remote inspection alone. A lot of iOS-specific behavior only shows up in standalone mode, and having an in-app panel to poke at it without a build cycle saved a lot of time.