fix(nav): keep bottom tab bar flush to visible viewport on iOS Chrome
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

iOS Chrome (and some Android browsers) auto-hide their URL bar on
scroll. This grows the visual viewport without changing the layout
viewport, so a position:fixed bar pinned to bottom:0 — which is
relative to the layout viewport — appears to float mid-screen with
a gap beneath it. Safari masks the bug because its toolbar shrinks
rather than fully retracting.

Track the delta between the visual and layout viewports via the
VisualViewport API and write it to a --mobile-bar-offset CSS var.
The bar uses translate3d to apply that offset, which both fixes the
gap and enables hardware compositing so it tracks the toolbar
animation without flicker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tudor Sitaru
2026-05-18 15:38:28 +01:00
parent 4acfd21883
commit a5be07ac0f
2 changed files with 33 additions and 0 deletions
@@ -233,5 +233,10 @@
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06); box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
/* Respect iPhone home-indicator inset */ /* Respect iPhone home-indicator inset */
padding-bottom: env(safe-area-inset-bottom, 0); padding-bottom: env(safe-area-inset-bottom, 0);
/* Compensate for iOS Chrome's auto-hiding URL bar — Navigation.tsx
writes the offset based on the Visual Viewport API. translate3d
(instead of translateY) forces hardware compositing so the bar
doesn't lag/flicker during the toolbar animation. */
transform: translate3d(0, var(--mobile-bar-offset, 0px), 0);
} }
} }
+28
View File
@@ -5,6 +5,7 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison'; import { useComparison } from '@/hooks/useComparison';
@@ -50,6 +51,33 @@ export function Navigation() {
return pathname.startsWith(path); return pathname.startsWith(path);
}; };
/**
* iOS Chrome (and some Android browsers) auto-hide their URL bar on scroll,
* which grows the visual viewport without changing the layout viewport.
* `position: fixed; bottom: 0` sticks to the layout viewport, so our tab
* bar appears to float mid-screen with a gap beneath it. Track the delta
* via VisualViewport and apply it as a translate so the bar always sits
* flush against the visible bottom edge.
*/
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
const update = () => {
const offset = window.innerHeight - (vv.height + vv.offsetTop);
// Only positive offsets are meaningful (bar hidden → push down).
root.style.setProperty('--mobile-bar-offset', `${Math.max(0, offset)}px`);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
root.style.removeProperty('--mobile-bar-offset');
};
}, []);
const items = [ const items = [
{ href: '/', label: 'Search', Icon: SearchIcon }, { href: '/', label: 'Search', Icon: SearchIcon },
{ href: '/compare', label: 'Compare', Icon: CompareIcon }, { href: '/compare', label: 'Compare', Icon: CompareIcon },