11"use client" ;
22
33import { 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" ] ) ;
814const EASE = "cubic-bezier(0.22, 1, 0.36, 1)" ;
915const SLIDE_MS = 560 ;
1016const DOCK_AT = 80 ;
1117
1218type 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.
1429export 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 ) }
0 commit comments