Skip to content
jswnthui

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.

kinematics.

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.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
wordmarkstring"kinematics."Shown at the top left.
inkstring"#0f1113"Colour used over light sections. Fixed rather than theme-derived, so it stays readable on a page whose own sections are light.
linksMenuItemData[]5 linksEach needs a `link`, `text` and `image` for its marquee band.
darkSectionsstring[]5 idsElement ids to watch; the header goes light while one is behind it.