Reference Guide

iOS PWA Tab Bar Fix

Why PWA standalone and Safari browser need different bottom nav positioning — and the complete pattern to fix both.


01 The Problem

A position: fixed; bottom: 0 tab bar on iOS behaves differently in Safari browser vs PWA standalone mode.

Safe-Area Double-Counting Bug

In PWA standalone mode, iOS constrains the viewport to end above the home indicator. The 62px below the viewport is managed by iOS — your app cannot render there.

Adding env(safe-area-inset-bottom) as padding inside the viewport pushes the tab bar up by 34px for no reason. The OS already handled it.

Safari Toolbar Gap Different Bug

In Safari browser mode, a dynamic toolbar expands/collapses at the bottom as the user scrolls. env(safe-area-inset-bottom) only reports the 34px home indicator — it does not include the toolbar height. The tab bar can end up behind the toolbar.

One CSS rule cannot fix both modes. You need runtime detection + separate CSS variables per mode.

02 iOS Viewport Anatomy

Measured on iPhone 14 Pro (iOS 18). The relationships are consistent across all notched / Dynamic Island iPhones.

PWA Standalone
Status Bar (59px)
App Viewport
894px
innerHeight
34px wasted gap
iOS Zone (62px)
safe-area: 34px
Viewport ends above home indicator.
env() double-counts if used as spacer.
Safari Browser
Status Bar
Address Bar
App Viewport
~745px
innerHeight (varies)
hidden behind toolbar
Toolbar (~50px)
safe-area: 34px
Toolbar expands/collapses on scroll.
env() does NOT track toolbar.
Key formula: screen.height (956) - innerHeight (894) = 62px managed by iOS below the PWA viewport. 34px of that is the home indicator. The remaining 28px is un-addressable by CSS.

03 Two Modes

What goes wrong in each mode, and what CSS variable fixes it.

Safari Browser Dynamic

Safari has a dynamic bottom toolbar that slides in/out. The visualViewport API tracks the visible viewport, which shrinks when the toolbar appears.

  • innerHeight - visualViewport.height = toolbar height
  • Set --safari-toolbar-h to this value
  • env(safe-area-inset-bottom) is correct here — fills home indicator zone

PWA Standalone Static

No Safari toolbar. The viewport ends above the home indicator (iOS manages the 62px zone).

  • --safari-toolbar-h is always 0px
  • Zero out the safe-area spacer (it double-counts)
  • Use --pwa-nav-nudge: -12px for fine-tuning

Before Fix (PWA)

viewport
34px wasted
tab bar floats too high

After Fix (PWA)

viewport
snug at viewport bottom

Clarity Tab Bar & More Drawer

Actual component from Clarity (mobile-nav.tsx). 4 primary tabs + "More" opens a bottom sheet.

9:41
Today
Morning Routine
3 of 5 complete
Tasks
2 high priority
Today
Chat
Routines
Settings
More
Tab bar — Today active (amber)
9:41
Today
Morning Routine
Life Context
Profile
Today
Chat
Routines
Settings
More
More drawer — bottom sheet (rounded-t-xl)

Key Implementation Details

  • min-height: 44px on all tab items (HIG touch target)
  • Active tab: text-clarity-amber / Inactive: text-muted-foreground
  • More opens shadcn <Sheet side="bottom"> with rounded-t-xl
  • Sheet items also get min-h-[44px] touch targets
  • Tab bar uses overflow: visible for FAB support
  • Safe-area spacer (.h-safe-bottom) sits below tab bar content, inside the nav

04 CSS Variables

All the moving parts and who sets them.

VariableSet BySafariPWA
--safari-toolbar-huseSafariToolbar hook 0-50px (dynamic) 0px
--pwa-nav-nudgeCSS html[data-standalone] 0px -12px
--tab-bar-heightCSS custom property 56px
env(safe-area-inset-bottom)iOS (read-only) 34px (notched iPhones)
data-standaloneuseSafariToolbar hook absent present

How They Compose

Nav bar bottom position:

CSS bottom: calc(var(--safari-toolbar-h, 0px) + var(--pwa-nav-nudge, 0px));

Body clearance (.pb-safe-nav):

CSS /* Safari browser */ padding-bottom: calc( var(--tab-bar-height) + env(safe-area-inset-bottom, 0px) + var(--safari-toolbar-h, 0px) ); /* PWA standalone override */ html[data-standalone] .pb-safe-nav { padding-bottom: var(--tab-bar-height); }

Safe-area spacer (.h-safe-bottom):

CSS .h-safe-bottom { height: env(safe-area-inset-bottom, 0px); } /* PWA: iOS already handles this zone */ html[data-standalone] .h-safe-bottom { height: 0px; }

05 The Hook: useSafariToolbar

React hook that detects the mode on mount and tracks Safari's dynamic toolbar via visualViewport.

TypeScript "use client" import { useEffect } from "react" export function useSafariToolbar() { useEffect(() => { // 1. Detect standalone mode const standalone = window.matchMedia("(display-mode: standalone)").matches || (window.navigator as Navigator & { standalone?: boolean }) .standalone === true const root = document.documentElement if (standalone) { // 2a. PWA: no toolbar, set attr for CSS root.setAttribute("data-standalone", "") root.style.setProperty("--safari-toolbar-h", "0px") return } // 2b. Safari browser mode root.removeAttribute("data-standalone") if (!window.visualViewport) { root.style.setProperty("--safari-toolbar-h", "0px") return } // 3. Track dynamic toolbar function onResize() { const offset = window.innerHeight - window.visualViewport!.height root.style.setProperty( "--safari-toolbar-h", `${Math.max(0, offset)}px` ) } onResize() window.visualViewport.addEventListener("resize", onResize) return () => { window.visualViewport?.removeEventListener("resize", onResize) } }, []) }

Key Decisions

  • Checks both CSS media query and legacy navigator.standalone (covers Safari + Chrome)
  • Sets data-standalone on <html> so CSS can target both modes without JS
  • visualViewport.resize fires when Safari toolbar animates in/out
  • Cleanup on unmount prevents memory leaks

06 Vanilla JS (No React)

Drop-in <script> tag for Astro, Hugo, WordPress, Svelte, Vue, or plain HTML. Zero dependencies.

HTML <script> (function() { var sa = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; var root = document.documentElement; if (sa) { root.setAttribute("data-standalone", ""); root.style.setProperty("--safari-toolbar-h", "0px"); return; } root.removeAttribute("data-standalone"); if (!window.visualViewport) { root.style.setProperty("--safari-toolbar-h", "0px"); return; } function onResize() { var offset = window.innerHeight - window.visualViewport.height; root.style.setProperty( "--safari-toolbar-h", Math.max(0, offset) + "px" ); } onResize(); window.visualViewport.addEventListener("resize", onResize); })(); </script>

Place before </body>. Same logic as the React hook.

Framework Adapters

FrameworkWhere to Put It
AstroAdd <script> in Layout.astro (client-side auto)
Vue 3Call IIFE logic inside onMounted() in layout component
SvelteUse onMount() in +layout.svelte
WordPressEnqueue in wp_footer action or add to theme footer.php
HugoAdd to layouts/partials/footer.html

07 Debug Widget

Interactive, auto-adjusting diagnostic tool. A floating gear icon opens a panel with CSS variable controls, mode presets, live viewport readout, and shareable Copy Config output. The widget IS the useSafariToolbar hook with a visible UI.

Tab Bar Debug AUTO
--safari-toolbar-h 0px
--pwa-nav-nudge -12px
mode:    PWA
screen:  956
inner:   894
vv:      894
toolbar: 0px
nudge:   -12px
nav.bot: -12px
Interactive mockup — click gear icon to toggle panel
Use presets and sliders to see how CSS vars change

Widget Features

Auto-adjusts on load
Mode toggle PWA / Safari
4 presets for quick testing
CSS sliders tune in real time
Live readout of all values
Copy Config shareable output
LabelAPIWhat It Tells You
PWA / SafarimatchMedia display-modeWhich mode the app is in
screenscreen.heightFull physical screen (constant per device)
innerwindow.innerHeightLayout viewport (includes area behind toolbar)
vvvisualViewport.heightVisible area (shrinks when toolbar appears)
toolbarCSS --safari-toolbar-hToolbar height from hook (0px in PWA)
nudgeCSS --pwa-nav-nudgePosition adjustment (-12px in PWA, 0 in Safari)
Workflow: Add widget script before </body>. Deploy. Open on iPhone. Widget auto-adjusts on load. Use presets to test modes. Copy Config. Share diagnostics. Remove before shipping.

08 The Fix Pattern

Step-by-step for any project with a fixed bottom nav on iOS.

1 Detect mode

Create the useSafariToolbar hook (Section 5). Call it from your bottom nav component. It sets data-standalone on <html> and --safari-toolbar-h.

2 Define CSS variables

CSS :root { --safari-toolbar-h: 0px; --pwa-nav-nudge: 0px; } html[data-standalone] { --pwa-nav-nudge: -12px; }

3 Position the nav bar

JSX <nav style={{ position: "fixed", bottom: "calc(var(--safari-toolbar-h, 0px) + var(--pwa-nav-nudge, 0px))", left: 0, right: 0, }}> {/* tab bar content */} <div className="h-safe-bottom" /> </nav>

4 Zero out spacer in PWA mode

CSS .h-safe-bottom { height: env(safe-area-inset-bottom, 0px); } html[data-standalone] .h-safe-bottom { height: 0px; }

5 Set body clearance

CSS @media (max-width: 767px) { .pb-safe-nav { padding-bottom: calc( var(--tab-bar-height) + env(safe-area-inset-bottom, 0px) + var(--safari-toolbar-h, 0px) ); } } html[data-standalone] .pb-safe-nav { padding-bottom: var(--tab-bar-height); }

6 PWA background color

CSS @media (display-mode: standalone) { html { background-color: var(--background); } }

7 Test with debug banner

Add <DebugBanner />, deploy, test on device. PWA should show tbh:0px. Safari should show toolbar height changing on scroll. Remove before shipping.


09 Device Measurements

Captured from iPhone 14 Pro running iOS 18 via the debug banner.

PropertyPWA StandaloneSafari Browser
screen.height956px956px
window.innerHeight894px~745px (varies)
visualViewport.height894px~695px (toolbar expanded)
safe-area-inset-bottom34px34px
iOS-managed zone62px (956 - 894)n/a
--safari-toolbar-h0px~50px (dynamic)
--pwa-nav-nudge-12px0px

Key Relationships

  • screen.height - innerHeight = iOS-managed zone below PWA viewport (62px)
  • innerHeight - visualViewport.height = Safari toolbar height (0-50px, dynamic)
  • env(safe-area-inset-bottom) = home indicator only (34px), subset of the 62px
  • Remaining 28px (62 - 34) is un-addressable by CSS — native apps only
Device variations: iPhone SE has no notch (0px safe area). iPad has no home indicator. Always test with the debug banner on target devices rather than hardcoding pixel values.

10 Playwright Test

E2E test to catch regressions. Simulates iPhone 14 Pro viewport and verifies CSS rules.

TypeScript import { test, expect } from "@playwright/test" test.describe("iOS Tab Bar Positioning", () => { test.use({ viewport: { width: 390, height: 844 } }) test("nav at viewport bottom", async ({ page }) => { await page.goto("/") const nav = page.locator("nav").first() const bottom = await nav.evaluate( el => parseFloat(getComputedStyle(el).bottom) ) expect(bottom).toBeLessThanOrEqual(0) }) test("PWA mode zeroes spacer", async ({ page }) => { await page.goto("/") await page.locator("html").evaluate( el => el.setAttribute("data-standalone", "") ) const h = await page.locator(".h-safe-bottom") .first() .evaluate(el => getComputedStyle(el).height) expect(h).toBe("0px") }) test("Safari mode preserves spacer rule", async ({ page }) => { await page.goto("/") await page.locator("html").evaluate( el => el.removeAttribute("data-standalone") ) // Verify CSS rule uses env(), not hardcoded 0px const rule = await page.evaluate(() => { for (const s of document.styleSheets) for (const r of s.cssRules) if (r.selectorText === ".h-safe-bottom") return r.style.height return null }) expect(rule).toContain("env(safe-area-inset-bottom") }) })
Note: env(safe-area-inset-bottom) resolves to 0px in Playwright (no real device safe area). The tests verify CSS rule structure, not pixel values. For pixel-level testing, use the debug banner on a real iOS device.

Running

Shell npx playwright test --grep "Tab Bar" # Or with mobile Safari project: npx playwright test --project="Mobile Safari" --grep "Tab Bar"