Skip to content

Commit 0cb42df

Browse files
Merge pull request #2241 from CapSoftware/fix/site-header-jump
fix: keep the site header identical across pages
2 parents 321ae61 + a6daa8a commit 0cb42df

3 files changed

Lines changed: 46 additions & 17 deletions

File tree

apps/web/app/(site)/Navbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface NavbarProps {
1212
export const Navbar = ({ stars }: NavbarProps) => {
1313
return (
1414
<NavbarFrame>
15-
<div className="flex gap-4 justify-between items-center px-5 mx-auto h-[68px] max-w-[1440px] group-data-[flat=true]:max-w-none group-data-[island=true]:h-[60px] group-data-[island=true]:px-4 lg:h-[76px] lg:px-8 lg:group-data-[island=true]:h-[64px] lg:group-data-[island=true]:px-5 xl:gap-6">
15+
<div className="flex gap-4 justify-between items-center px-5 mx-auto h-[68px] group-data-[island=true]:h-[60px] group-data-[island=true]:px-4 lg:h-[76px] lg:px-8 lg:group-data-[island=true]:h-[64px] lg:group-data-[island=true]:px-5 xl:gap-6">
1616
<div className="flex gap-2 items-center lg:gap-3 xl:gap-6">
1717
<Link passHref href="/home" className="shrink-0">
1818
<Logo

apps/web/app/(site)/NavbarFrame.tsx

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,53 @@
11
"use client";
22

33
import { classNames } from "@cap/utils/helpers";
4-
import { usePathname } from "next/navigation";
5-
import { type ReactNode, useEffect, useState } from "react";
4+
import {
5+
type ReactNode,
6+
useCallback,
7+
useEffect,
8+
useLayoutEffect,
9+
useRef,
10+
useState,
11+
useSyncExternalStore,
12+
} from "react";
613

7-
const FLAT_HEADER_ROUTES = new Set(["/", "/home"]);
814
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
915
const SLIDE_MS = 560;
1016
const DOCK_AT = 80;
1117

1218
type Phase = "docked" | "entering" | "floating" | "leaving";
1319

20+
const readSentinel = () => document.querySelector("[data-header-sentinel]");
21+
const noSentinel = () => null;
22+
23+
// The page declares the flat treatment with `data-header-flat` on its root, a
24+
// sibling of this header, and `:has(~ ...)` applies it in the prerendered HTML.
25+
// The island is driven by the page's `data-header-sentinel`, re-read whenever
26+
// the sibling page changes. Neither depends on `usePathname()`: Next prerenders
27+
// `/` with pathname `/index`, so a route-keyed header rendered the fixed bar on
28+
// the server and React kept those attributes after hydration.
1429
export const NavbarFrame = ({ children }: { children: ReactNode }) => {
15-
const pathname = usePathname();
16-
const flat = FLAT_HEADER_ROUTES.has(pathname);
30+
const headerRef = useRef<HTMLElement>(null);
1731
const [phase, setPhase] = useState<Phase>("docked");
1832

19-
useEffect(() => {
20-
if (!FLAT_HEADER_ROUTES.has(pathname)) return;
21-
const sentinel = document.querySelector("[data-header-sentinel]");
22-
if (!sentinel) return;
33+
const subscribeToPage = useCallback((onChange: () => void) => {
34+
const slot = headerRef.current?.parentElement;
35+
if (!slot) return () => {};
36+
const observer = new MutationObserver(onChange);
37+
observer.observe(slot, { childList: true });
38+
return () => observer.disconnect();
39+
}, []);
40+
const sentinel = useSyncExternalStore(
41+
subscribeToPage,
42+
readSentinel,
43+
noSentinel,
44+
);
45+
46+
useLayoutEffect(() => {
47+
if (!sentinel) {
48+
setPhase("docked");
49+
return;
50+
}
2351
const io = new IntersectionObserver(([entry]) => {
2452
const past = entry
2553
? !entry.isIntersecting && entry.boundingClientRect.top < 0
@@ -37,7 +65,7 @@ export const NavbarFrame = ({ children }: { children: ReactNode }) => {
3765
});
3866
io.observe(sentinel);
3967
return () => io.disconnect();
40-
}, [pathname]);
68+
}, [sentinel]);
4169

4270
useEffect(() => {
4371
if (phase === "entering") {
@@ -64,23 +92,23 @@ export const NavbarFrame = ({ children }: { children: ReactNode }) => {
6492
}
6593
}, [phase]);
6694

67-
const island = flat && phase !== "docked";
95+
const island = sentinel !== null && phase !== "docked";
6896
const shown = phase === "floating";
6997

7098
return (
7199
<header
72-
data-flat={flat ? "true" : "false"}
100+
ref={headerRef}
73101
data-island={island ? "true" : "false"}
74102
className={classNames(
75-
"group pointer-events-none inset-x-0 top-0 z-[51]",
76-
flat && !island ? "absolute" : "fixed",
103+
"group pointer-events-none inset-x-0 top-0 z-[51] fixed",
104+
!island && "has-[~[data-header-flat]]:absolute",
77105
)}
78106
>
79107
<div
80108
className={classNames(
81109
"pointer-events-auto mx-auto",
82-
!flat && "max-w-none border-b border-zinc-200/70 bg-white",
83-
flat && !island && "bg-transparent",
110+
!island &&
111+
"border-b border-zinc-200/70 bg-white group-has-[~[data-header-flat]]:border-b-0 group-has-[~[data-header-flat]]:bg-transparent",
84112
island &&
85113
"mt-3 max-w-[calc(100%-24px)] rounded-[18px] bg-white/90 shadow-[0_0_0_1px_rgba(17,17,17,0.06)] backdrop-blur-xl lg:mt-4 lg:max-w-[min(1200px,calc(100%-32px))]",
86114
)}

apps/web/components/pages/HomeTwo/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function HomeTwoPage() {
2323
// over them instead.
2424
return (
2525
<div
26+
data-header-flat
2627
className={`${htSans.className} ${htSans.variable} ${htSerif.variable} ${htMono.variable} text-[#111111]`}
2728
style={grainBg(SHELL)}
2829
>

0 commit comments

Comments
 (0)