@@ -22,13 +22,15 @@ interface NavbarFrostContextValue {
2222}
2323
2424const NavbarFrostContext = createContext < NavbarFrostContextValue | null > ( null )
25+ const SCROLL_DIRECTION_THRESHOLD = 8
2526
2627/** Lets each nav surface report its open state so the shell can coordinate shared effects. */
2728export function useNavbarFrost ( ) : NavbarFrostContextValue | null {
2829 return use ( NavbarFrostContext )
2930}
3031
3132interface NavbarShellProps {
33+ announcement ?: ReactNode
3234 children : ReactNode
3335}
3436
@@ -39,8 +41,7 @@ interface NavbarShellProps {
3941 * At the very top the bar uses the same solid canvas token as the hero, so it is
4042 * visually seamless while still preventing route content from painting through
4143 * the sticky header. A 1px sentinel at the top of the landing shell's internal
42- * scroll port is watched by an {@link IntersectionObserver} - no scroll listener
43- * and no per-frame work. Past that point the bar gains the shared
44+ * scroll port is watched by an {@link IntersectionObserver}. Past that point the bar gains the shared
4445 * {@link NAVBAR_GLASS_SURFACE} (`--bg` at 92% via `color-mix` plus a strong 40px
4546 * backdrop blur) - a white/glass surface built entirely from the platform's
4647 * light tokens, not invented colors.
@@ -51,8 +52,12 @@ interface NavbarShellProps {
5152 * while the fill still fades, so the frost appears smoothly without the jitter.
5253 *
5354 * The measured header height anchors the desktop panel and bounds the mobile
54- * sheet, including changes to the announcement strip or text sizing. The same
55- * height offsets native page and hash scrolling inside the landing scroll port.
55+ * sheet, including changes to the announcement strip or text sizing. Native
56+ * page and hash scrolling reserve the full height so changing banner visibility
57+ * does not move the scroll anchor and leaves room for the banner to return.
58+ * Scrolling down slides the announcement above the viewport; scrolling up
59+ * restores it. Moving the sticky inset preserves document flow and scroll
60+ * position. Menu offsets use only the visible portion of the header.
5661 *
5762 * Both navigation surfaces report open state through {@link NavbarFrostContext}.
5863 * While either is open, the shell locks its actual scroll port, preserves the
@@ -75,10 +80,12 @@ interface NavbarShellProps {
7580 * Only this shell hydrates; the nav content is server-rendered and passed through
7681 * as {@link children}, so the wordmark and links stay zero-hydration and crawlable.
7782 */
78- export function NavbarShell ( { children } : NavbarShellProps ) {
83+ export function NavbarShell ( { announcement , children } : NavbarShellProps ) {
7984 const sentinelRef = useRef < HTMLDivElement > ( null )
8085 const headerRef = useRef < HTMLElement > ( null )
86+ const announcementRef = useRef < HTMLDivElement > ( null )
8187 const [ scrolled , setScrolled ] = useState ( false )
88+ const [ announcementHidden , setAnnouncementHidden ] = useState ( false )
8289 const [ menuOpenBySource , setMenuOpenBySource ] = useState ( { desktop : false , mobile : false } )
8390 const menuOpen = menuOpenBySource . desktop || menuOpenBySource . mobile
8491
@@ -88,24 +95,50 @@ export function NavbarShell({ children }: NavbarShellProps) {
8895 if ( ! header || ! scrollPort ) return
8996
9097 const previousScrollPaddingTop = scrollPort . style . scrollPaddingTop
91- let previousHeight = 0
9298 const updateHeight = ( ) => {
9399 const height = header . getBoundingClientRect ( ) . height
94- if ( height === previousHeight ) return
95- previousHeight = height
96- header . style . setProperty ( '--landing-header-height' , `${ height } px` )
100+ const announcementHeight = announcementRef . current ?. getBoundingClientRect ( ) . height ?? 0
101+ header . style . setProperty ( '--landing-announcement-height' , `${ announcementHeight } px` )
102+ header . style . setProperty (
103+ '--landing-header-height' ,
104+ `calc(${ height } px - var(--landing-announcement-offset, 0px))`
105+ )
97106 scrollPort . style . scrollPaddingTop = `${ height } px`
98107 }
99108
100109 updateHeight ( )
101110 const observer = new ResizeObserver ( updateHeight )
102111 observer . observe ( header )
112+ if ( announcementRef . current ) observer . observe ( announcementRef . current )
103113 return ( ) => {
104114 observer . disconnect ( )
105115 scrollPort . style . scrollPaddingTop = previousScrollPaddingTop
106116 }
107117 } , [ ] )
108118
119+ useEffect ( ( ) => {
120+ const scrollPort = sentinelRef . current ?. parentElement
121+ const banner = announcementRef . current
122+ if ( ! scrollPort || ! banner ) return
123+
124+ const scrollPosition = ( ) =>
125+ Math . max ( 0 , Math . min ( scrollPort . scrollTop , scrollPort . scrollHeight - scrollPort . clientHeight ) )
126+ let previousPosition = scrollPosition ( )
127+ const onScroll = ( ) => {
128+ const position = scrollPosition ( )
129+ const delta = position - previousPosition
130+ const nearTop = position <= banner . offsetHeight
131+ if ( ! nearTop && ( menuOpen || Math . abs ( delta ) < SCROLL_DIRECTION_THRESHOLD ) ) return
132+
133+ previousPosition = position
134+ if ( banner . contains ( document . activeElement ) ) return
135+ setAnnouncementHidden ( ! nearTop && delta > 0 )
136+ }
137+
138+ scrollPort . addEventListener ( 'scroll' , onScroll , { passive : true } )
139+ return ( ) => scrollPort . removeEventListener ( 'scroll' , onScroll )
140+ } , [ menuOpen ] )
141+
109142 useEffect ( ( ) => {
110143 const sentinel = sentinelRef . current
111144 if ( ! sentinel ) return
@@ -166,7 +199,13 @@ export function NavbarShell({ children }: NavbarShellProps) {
166199 < header
167200 ref = { headerRef }
168201 data-landing-header
169- className = 'sticky top-0 z-50 [--landing-header-height:calc(1.95rem_+_62px)]'
202+ className = { cn (
203+ 'sticky z-50 transition-[top] duration-200 ease-out [--landing-announcement-height:1.95rem] [--landing-header-height:calc(1.95rem_+_62px)] motion-reduce:transition-none' ,
204+ announcementHidden
205+ ? '-top-[var(--landing-announcement-height)] [--landing-announcement-offset:var(--landing-announcement-height)]'
206+ : 'top-0 [--landing-announcement-offset:0px]' ,
207+ menuOpen && 'transition-none'
208+ ) }
170209 >
171210 < div
172211 aria-hidden = 'true'
@@ -175,6 +214,11 @@ export function NavbarShell({ children }: NavbarShellProps) {
175214 scrolled || menuOpen ? NAVBAR_GLASS_SURFACE : 'bg-[var(--bg)]'
176215 ) }
177216 />
217+ { announcement && (
218+ < div ref = { announcementRef } inert = { announcementHidden } aria-hidden = { announcementHidden } >
219+ { announcement }
220+ </ div >
221+ ) }
178222 { children }
179223 </ header >
180224 < div
0 commit comments