Navigation
Flowing Header
A header that flips light-on-dark as dark sections pass behind it, with a scrambling contact button and a full-screen menu whose rows peel open into a marquee.
A light section, where the header reads dark-on-light.
A dark section carrying a watched id, so the header flips to light-on-dark while it is behind it.
Light again, and the header flips back.
Scroll past the dark band — the header flips to light. Open the menu with the hamburger.
import { FlowingHeader } from "@/components/ui/flowing-header";
export default function FlowingHeaderDemo({ fullscreen }: { fullscreen?: boolean }) {
if (fullscreen) {
return (
<div className="relative w-full">
<FlowingHeader />
<Page />
</div>
);
}
return (
<div className="w-full min-w-0 space-y-3">
{/* The header positions against its nearest positioned ancestor. */}
<div className="scrollbar-thin relative h-[520px] w-full overflow-y-auto rounded-card border border-line">
<FlowingHeader />
<Page />
</div>
<p className="text-center text-xs text-faint">
Scroll past the dark band — the header flips to light. Open the menu with
the hamburger.
</p>
</div>
);
}
/** Enough page to show the header flipping over a dark section. */
function Page() {
return (
<>
<section className="grid h-[420px] place-items-center bg-[#f4f4f6] px-8 text-center">
<p className="max-w-md text-2xl font-semibold tracking-tight text-[#0f1113]">
A light section, where the header reads dark-on-light.
</p>
</section>
<section
id="work"
className="grid h-[520px] place-items-center bg-[#0f1113] px-8 text-center"
>
<p className="max-w-md text-2xl font-semibold tracking-tight text-white">
A dark section carrying a watched id, so the header flips to
light-on-dark while it is behind it.
</p>
</section>
<section className="grid h-[420px] place-items-center bg-[#f4f4f6] px-8 text-center">
<p className="max-w-md text-2xl font-semibold tracking-tight text-[#0f1113]">
Light again, and the header flips back.
</p>
</section>
</>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/flowing-header.tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import FlowingMenu, { type MenuItemData } from "@/components/ui/flowing-menu"; const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const DEFAULT_LINKS: MenuItemData[] = [ { link: "/", text: "Home", image: "https://picsum.photos/seed/nav-home/400/260", }, { link: "/work", text: "Work", image: "https://picsum.photos/seed/nav-work/400/260", }, { link: "/services", text: "Services", image: "https://picsum.photos/seed/nav-services/400/260", }, { link: "/about", text: "About", image: "https://picsum.photos/seed/nav-about/400/260", }, { link: "/contact", text: "Contact", image: "https://picsum.photos/seed/nav-contact/400/260", }, ]; /** Ids of sections dark enough that the header has to flip to light-on-dark. */ const DEFAULT_DARK_SECTIONS = ["stack", "work", "services", "about", "contact"]; type FlowingHeaderProps = { wordmark?: string; /** Colour used over light sections. Fixed, not theme-derived — see below. */ ink?: string; links?: MenuItemData[]; /** Element ids to watch; the header goes light while one is behind it. */ darkSections?: string[]; className?: string; }; /** Decrypt-style hover effect: characters randomize then lock in left-to-right, revealing `text`. */ function useScramble(text: string) { const [display, setDisplay] = useState(text); const frameRef = useRef(0); const rafRef = useRef<number | null>(null); const start = useCallback(() => { if (rafRef.current) cancelAnimationFrame(rafRef.current); const totalFrames = text.length * 4; frameRef.current = 0; const tick = () => { const frame = frameRef.current; const revealed = Math.floor((frame / totalFrames) * text.length); const next = text .split("") .map((char, i) => { if (char === " ") return " "; if (i < revealed) return char; return SCRAMBLE_CHARS[ Math.floor(Math.random() * SCRAMBLE_CHARS.length) ]; }) .join(""); setDisplay(next); frameRef.current += 1; if (frame < totalFrames) { rafRef.current = requestAnimationFrame(tick); } else { setDisplay(text); } }; rafRef.current = requestAnimationFrame(tick); }, [text]); const reset = useCallback(() => { if (rafRef.current) cancelAnimationFrame(rafRef.current); setDisplay(text); }, [text]); useEffect(() => { return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, []); return { display, start, reset }; } function ContactButton({ light, ink }: { light: boolean; ink: string }) { const { display, start, reset } = useScramble("Contact"); return ( <a href="/contact" onMouseEnter={start} onMouseLeave={reset} style={{ color: light ? "#fff" : ink, borderColor: light ? "#fff" : ink }} className="group inline-flex items-center gap-2.5 rounded-[50px] border-2 px-6 py-2.5 text-sm font-bold tracking-wider uppercase transition-colors duration-300 hover:opacity-80" > <span className="w-[7ch]">{display}</span> <svg width="20" height="20" viewBox="0 0 16 16" fill="none" className="shrink-0 transition-transform duration-300 ease-out group-hover:-translate-y-1 group-hover:translate-x-1" > <path d="M4 12L12 4M12 4H5M12 4V11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> </a> ); } function HamburgerButton({ light, ink, open, onToggle, }: { light: boolean; ink: string; open: boolean; onToggle: () => void; }) { // Once the menu is open, its own overlay is always dark — so the button // needs to read as light-on-dark regardless of what's behind the header, // otherwise a near-black icon would vanish against the open menu. const showLight = light || open; const tone = showLight ? "#fff" : ink; return ( <button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} onClick={onToggle} style={{ borderColor: tone }} className="relative z-[110] flex h-11 w-11 shrink-0 flex-col items-center justify-center gap-1.5 rounded-md border-2 transition-colors duration-300 hover:opacity-80" > <span style={{ backgroundColor: tone }} className={`block h-0.5 w-7 transition-transform duration-300 ${ open ? "translate-y-[5px] rotate-45" : "" }`} /> <span style={{ backgroundColor: tone }} className={`block h-0.5 w-7 transition-transform duration-300 ${ open ? "-translate-y-[5px] -rotate-45" : "" }`} /> </button> ); } function NavOverlay({ open, links, onClose, }: { open: boolean; links: MenuItemData[]; onClose: () => void; }) { return ( <div className={`absolute inset-0 z-[105] transition-opacity duration-500 ease-out ${ open ? "opacity-100" : "pointer-events-none opacity-0" }`} > <FlowingMenu items={links} speed={20} textColor="#fff" bgColor="#0f1113" marqueeBgColor="#ceff00" marqueeTextColor="#0f1113" borderColor="rgba(255,255,255,0.15)" onItemClick={onClose} /> </div> ); } export function FlowingHeader({ wordmark = "kinematics.", ink = "#0f1113", links = DEFAULT_LINKS, darkSections = DEFAULT_DARK_SECTIONS, className, }: FlowingHeaderProps) { // Watches every dark section so the header's own dark-on-light styling can // flip to light-on-dark while one of them is actually behind the fixed // header — otherwise its near-black text and border would disappear // against that section's dark background. const [light, setLight] = useState(false); const [menuOpen, setMenuOpen] = useState(false); // No smooth-scroll library here, so the page is held the plain way. The // previous value is restored rather than cleared, so this cannot stomp on // another component's scroll lock. useEffect(() => { const previous = document.body.style.overflow; if (menuOpen) document.body.style.overflow = "hidden"; const handleKey = (event: KeyboardEvent) => { if (event.key === "Escape") setMenuOpen(false); }; window.addEventListener("keydown", handleKey); return () => { document.body.style.overflow = previous; window.removeEventListener("keydown", handleKey); }; }, [menuOpen]); useEffect(() => { // Astro serves each route as its own document, so an island mounts fresh // per page and this runs against the sections actually on screen. The // astro:page-load listener covers the one case where it would not — a // site using ClientRouter, where navigation swaps the DOM in place. let observer: IntersectionObserver | null = null; const watch = () => { observer?.disconnect(); const elements = darkSections .map((id) => document.getElementById(id)) .filter((el): el is HTMLElement => el !== null); if (elements.length === 0) { setLight(false); return; } const intersecting = new Set<Element>(); observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) intersecting.add(entry.target); else intersecting.delete(entry.target); } setLight(intersecting.size > 0); }, // The header itself is ~72-96px tall — only flip once a dark section // is actually behind that band, not the instant its edge appears at // the very bottom of the viewport. { rootMargin: "-96px 0px -80% 0px" }, ); elements.forEach((el) => observer!.observe(el)); }; watch(); document.addEventListener("astro:page-load", watch); return () => { observer?.disconnect(); document.removeEventListener("astro:page-load", watch); }; }, [darkSections]); return ( <> {/* A sibling of the header, not a child: the header is positioned, so nesting this inside it would resolve inset-0 against the header's own ~90px bar instead of the container. The header outranks it on z, so the wordmark and the close button stay above the open menu. */} <NavOverlay open={menuOpen} links={links} onClose={() => setMenuOpen(false)} /> <header className={`absolute inset-x-0 top-0 z-[110] flex items-center justify-between px-6 py-3 md:px-10 md:py-4 ${className ?? ""}`} > <a href="/" style={{ color: light || menuOpen ? "#fff" : ink }} className="relative z-[110] font-[family-name:var(--font-display,var(--font-sans))] text-xl tracking-tight uppercase transition-colors duration-200 hover:opacity-80 md:text-3xl" > {wordmark} </a> <div className="flex items-center gap-3 md:gap-4"> <div className={`relative z-[110] transition-opacity duration-200 ${menuOpen ? "pointer-events-none opacity-0" : "opacity-100"}`} > <ContactButton light={light} ink={ink} /> </div> <HamburgerButton light={light} ink={ink} open={menuOpen} onToggle={() => setMenuOpen((o) => !o)} /> </div> </header> </> ); }components/ui/flowing-menu.tsx "use client"; import React, { useRef, useEffect, useState } from 'react'; import { gsap } from 'gsap'; export interface MenuItemData { link: string; text: string; image: string; } interface FlowingMenuProps { items?: MenuItemData[]; speed?: number; textColor?: string; bgColor?: string; marqueeBgColor?: string; marqueeTextColor?: string; borderColor?: string; onItemClick?: () => void; } interface MenuItemProps extends MenuItemData { speed: number; textColor: string; marqueeBgColor: string; marqueeTextColor: string; borderColor: string; isFirst: boolean; onItemClick?: () => void; } const FlowingMenu: React.FC<FlowingMenuProps> = ({ items = [], speed = 15, textColor = '#fff', bgColor = '#120F17', marqueeBgColor = '#fff', marqueeTextColor = '#120F17', borderColor = '#fff', onItemClick }) => { return ( <div className="w-full h-full overflow-hidden" style={{ backgroundColor: bgColor }}> <nav className="flex flex-col h-full m-0 p-0"> {items.map((item, idx) => ( <MenuItem key={idx} {...item} speed={speed} textColor={textColor} marqueeBgColor={marqueeBgColor} marqueeTextColor={marqueeTextColor} borderColor={borderColor} isFirst={idx === 0} onItemClick={onItemClick} /> ))} </nav> </div> ); }; const MenuItem: React.FC<MenuItemProps> = ({ link, text, image, speed, textColor, marqueeBgColor, marqueeTextColor, borderColor, isFirst, onItemClick }) => { const itemRef = useRef<HTMLDivElement>(null); const marqueeRef = useRef<HTMLDivElement>(null); const marqueeInnerRef = useRef<HTMLDivElement>(null); const animationRef = useRef<gsap.core.Tween | null>(null); const [repetitions, setRepetitions] = useState(4); const animationDefaults = { duration: 0.6, ease: 'expo' }; const findClosestEdge = (mouseX: number, mouseY: number, width: number, height: number): 'top' | 'bottom' => { const topEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY, 2); const bottomEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY - height, 2); return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom'; }; useEffect(() => { const calculateRepetitions = () => { if (!marqueeInnerRef.current) return; const marqueeContent = marqueeInnerRef.current.querySelector('.marquee-part') as HTMLElement; if (!marqueeContent) return; const contentWidth = marqueeContent.offsetWidth; const viewportWidth = window.innerWidth; const needed = Math.ceil(viewportWidth / contentWidth) + 2; setRepetitions(Math.max(4, needed)); }; calculateRepetitions(); window.addEventListener('resize', calculateRepetitions); return () => window.removeEventListener('resize', calculateRepetitions); }, [text, image]); useEffect(() => { const setupMarquee = () => { if (!marqueeInnerRef.current) return; const marqueeContent = marqueeInnerRef.current.querySelector('.marquee-part') as HTMLElement; if (!marqueeContent) return; const contentWidth = marqueeContent.offsetWidth; if (contentWidth === 0) return; if (animationRef.current) { animationRef.current.kill(); } animationRef.current = gsap.to(marqueeInnerRef.current, { x: -contentWidth, duration: speed, ease: 'none', repeat: -1 }); }; const timer = setTimeout(setupMarquee, 50); return () => { clearTimeout(timer); if (animationRef.current) { animationRef.current.kill(); } }; }, [text, image, repetitions, speed]); const handleMouseEnter = (ev: React.MouseEvent<HTMLAnchorElement>) => { if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return; const rect = itemRef.current.getBoundingClientRect(); const edge = findClosestEdge(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height); gsap .timeline({ defaults: animationDefaults }) .set(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) .set(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0) .to([marqueeRef.current, marqueeInnerRef.current], { y: '0%' }, 0); }; const handleMouseLeave = (ev: React.MouseEvent<HTMLAnchorElement>) => { if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return; const rect = itemRef.current.getBoundingClientRect(); const edge = findClosestEdge(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height); gsap .timeline({ defaults: animationDefaults }) .to(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) .to(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0); }; return ( <div className="flex-1 relative overflow-hidden text-center" ref={itemRef} style={{ borderTop: isFirst ? 'none' : `1px solid ${borderColor}` }} > <a className="flex items-center justify-center h-full relative cursor-pointer uppercase no-underline font-semibold text-[4vh]" href={link} onClick={onItemClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} style={{ color: textColor }} > {text} </a> <div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none translate-y-[101%]" ref={marqueeRef} style={{ backgroundColor: marqueeBgColor }} > <div className="h-full w-fit flex" ref={marqueeInnerRef}> {[...Array(repetitions)].map((_, idx) => ( <div className="marquee-part flex items-center flex-shrink-0" key={idx} style={{ color: marqueeTextColor }}> <span className="whitespace-nowrap uppercase font-normal text-[4vh] leading-[1] px-[1vw]">{text}</span> <div className="w-[200px] h-[7vh] my-[2em] mx-[2vw] py-[1em] rounded-[50px] bg-cover bg-center" style={{ backgroundImage: `url(${image})` }} /> </div> ))} </div> </div> </div> ); }; export default FlowingMenu; export { FlowingMenu };
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| wordmark | string | "kinematics." | Shown at the top left. |
| ink | string | "#0f1113" | Colour used over light sections. Fixed rather than theme-derived, so it stays readable on a page whose own sections are light. |
| links | MenuItemData[] | 5 links | Each needs a `link`, `text` and `image` for its marquee band. |
| darkSections | string[] | 5 ids | Element ids to watch; the header goes light while one is behind it. |