From 63602a2f47397e2212187ab74264bf63b5a0d717 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Tue, 2 Jun 2026 14:12:38 +0200 Subject: [PATCH] fix: switch header to position: sticky (iOS 26 WebKit fixed jitter bug) Web search confirmed user is not alone. WebKit bug 297779 reports that on iOS 26 Safari + WKWebView (Telegram in-app browser, Chrome iOS), any position: fixed element drifts 10-24 px when scroll direction reverses, exposing the page under the header through a thin gap. Mastodon and LinkedIn ship the same symptom. Apple acknowledged the bug, partially fixed it in iOS 26.1 but residual reports persist into Dec 2025. position: sticky uses a different render path in WebKit that does not trip the jitter heuristic. It is also simpler: - no spacer div needed (the element stays in normal flow) - no useRef + ResizeObserver to keep the spacer in sync - no headerH state Net: -25 lines on top of fixing the cosmetic gap. Desktop and standalone Safari behavior are identical, sticky just renders more reliably in WebViews. Refs: - https://bugs.webkit.org/show_bug.cgi?id=297779 - https://github.com/mastodon/mastodon/issues/36144 - https://github.com/TelegramMessenger/Telegram-iOS/issues/1748 --- src/components/site-header.tsx | 43 +++++++++------------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index df56a851..138e641a 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -2,7 +2,7 @@ import { Menu, X } from "lucide-react"; import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { SiteLogoSwitcher } from "@/components/site-logo-switcher"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -24,10 +24,6 @@ const NAV = [ export function SiteHeader() { const [open, setOpen] = useState(false); - const headerRef = useRef(null); - // Initial estimate matches the rendered header height so the spacer - // doesn't jump on first paint; ResizeObserver refines it after mount. - const [headerH, setHeaderH] = useState(64); // Close the mobile menu when the viewport crosses md so the dropdown // doesn't stick around as the desktop nav reappears. @@ -41,29 +37,16 @@ export function SiteHeader() { return () => mql.removeEventListener("change", onChange); }, [open]); - // Measure the fixed header so we can reserve the same height in the - // flow with a spacer. ResizeObserver picks up viewport resizes. - useEffect(() => { - const el = headerRef.current; - if (!el) return; - const update = () => setHeaderH(el.offsetHeight); - update(); - const ro = new ResizeObserver(update); - ro.observe(el); - return () => ro.disconnect(); - }, []); - return ( - <> -
+
@@ -148,10 +131,6 @@ export function SiteHeader() { )}
-
- {/* Spacer reserves the fixed header's height in the document flow - so page content does not start under it. */} -
- +
); }