Why PWA standalone and Safari browser need different bottom nav positioning — and the complete pattern to fix both.
A position: fixed; bottom: 0 tab bar on iOS behaves differently in Safari browser vs PWA standalone mode.
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.
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.
Measured on iPhone 14 Pro (iOS 18). The relationships are consistent across all notched / Dynamic Island iPhones.
env() double-counts if used as spacer.
env() does NOT track toolbar.
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.
What goes wrong in each mode, and what CSS variable fixes it.
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--safari-toolbar-h to this valueenv(safe-area-inset-bottom) is correct here — fills home indicator zoneNo Safari toolbar. The viewport ends above the home indicator (iOS manages the 62px zone).
--safari-toolbar-h is always 0px--pwa-nav-nudge: -12px for fine-tuningActual component from Clarity (mobile-nav.tsx). 4 primary tabs + "More" opens a bottom sheet.
min-height: 44px on all tab items (HIG touch target)text-clarity-amber / Inactive: text-muted-foreground<Sheet side="bottom"> with rounded-t-xlmin-h-[44px] touch targetsoverflow: visible for FAB support.h-safe-bottom) sits below tab bar content, inside the navAll the moving parts and who sets them.
| Variable | Set By | Safari | PWA |
|---|---|---|---|
| --safari-toolbar-h | useSafariToolbar hook | 0-50px (dynamic) | 0px |
| --pwa-nav-nudge | CSS html[data-standalone] | 0px | -12px |
| --tab-bar-height | CSS custom property | 56px | |
| env(safe-area-inset-bottom) | iOS (read-only) | 34px (notched iPhones) | |
| data-standalone | useSafariToolbar hook | absent | present |
Nav bar bottom position:
bottom: calc(var(--safari-toolbar-h, 0px) + var(--pwa-nav-nudge, 0px));
Body clearance (.pb-safe-nav):
/* 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):
.h-safe-bottom { height: env(safe-area-inset-bottom, 0px); }
/* PWA: iOS already handles this zone */
html[data-standalone] .h-safe-bottom { height: 0px; }
React hook that detects the mode on mount and tracks Safari's dynamic toolbar via visualViewport.
"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)
}
}, [])
}
navigator.standalone (covers Safari + Chrome)data-standalone on <html> so CSS can target both modes without JSvisualViewport.resize fires when Safari toolbar animates in/outDrop-in <script> tag for Astro, Hugo, WordPress, Svelte, Vue, or plain HTML. Zero dependencies.
<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 | Where to Put It |
|---|---|
| Astro | Add <script> in Layout.astro (client-side auto) |
| Vue 3 | Call IIFE logic inside onMounted() in layout component |
| Svelte | Use onMount() in +layout.svelte |
| WordPress | Enqueue in wp_footer action or add to theme footer.php |
| Hugo | Add to layouts/partials/footer.html |
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.
| Label | API | What It Tells You |
|---|---|---|
| PWA / Safari | matchMedia display-mode | Which mode the app is in |
| screen | screen.height | Full physical screen (constant per device) |
| inner | window.innerHeight | Layout viewport (includes area behind toolbar) |
| vv | visualViewport.height | Visible area (shrinks when toolbar appears) |
| toolbar | CSS --safari-toolbar-h | Toolbar height from hook (0px in PWA) |
| nudge | CSS --pwa-nav-nudge | Position adjustment (-12px in PWA, 0 in Safari) |
</body>. Deploy. Open on iPhone. Widget auto-adjusts on load. Use presets to test modes. Copy Config. Share diagnostics. Remove before shipping.
Step-by-step for any project with a fixed bottom nav on iOS.
Create the useSafariToolbar hook (Section 5). Call it from your bottom nav component. It sets data-standalone on <html> and --safari-toolbar-h.
:root {
--safari-toolbar-h: 0px;
--pwa-nav-nudge: 0px;
}
html[data-standalone] {
--pwa-nav-nudge: -12px;
}
<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>
.h-safe-bottom {
height: env(safe-area-inset-bottom, 0px);
}
html[data-standalone] .h-safe-bottom {
height: 0px;
}
@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);
}
@media (display-mode: standalone) {
html { background-color: var(--background); }
}
Add <DebugBanner />, deploy, test on device. PWA should show tbh:0px. Safari should show toolbar height changing on scroll. Remove before shipping.
Captured from iPhone 14 Pro running iOS 18 via the debug banner.
| Property | PWA Standalone | Safari Browser |
|---|---|---|
| screen.height | 956px | 956px |
| window.innerHeight | 894px | ~745px (varies) |
| visualViewport.height | 894px | ~695px (toolbar expanded) |
| safe-area-inset-bottom | 34px | 34px |
| iOS-managed zone | 62px (956 - 894) | n/a |
| --safari-toolbar-h | 0px | ~50px (dynamic) |
| --pwa-nav-nudge | -12px | 0px |
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 62pxE2E test to catch regressions. Simulates iPhone 14 Pro viewport and verifies CSS rules.
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")
})
})
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.
npx playwright test --grep "Tab Bar"
# Or with mobile Safari project:
npx playwright test --project="Mobile Safari" --grep "Tab Bar"