Skip to content

Gather Nav

A plain bar that melts on scroll, gathering its links into a frosted pill.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/gather-nav.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/nav/mobile-menu.tsx
    "use client";
    
    import { useEffect, useId, useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { cn } from "@/lib/utils";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    
    export interface MobileMenuProps {
      items: NavItem[];
      actions?: NavAction[];
      /** Colors for light or dark navbars. @default "light" */
      tone?: "light" | "dark";
      /** Classes for the toggle button. */
      className?: string;
      /** Classes for the panel, to place it under narrow navbars. */
      panelClassName?: string;
    }
    
    /**
     * The compact menu every navbar falls back to on narrow widths: a hamburger that morphs into an X,
     * and a panel with expandable groups and the actions. Escape closes it and returns focus.
     */
    export function MobileMenu({ items, actions = [], tone = "light", className, panelClassName }: MobileMenuProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [open, setOpen] = useState(false);
      const [group, setGroup] = useState<string | null>(null);
      const dark = tone === "dark";
    
      useEffect(() => {
        if (!open) return;
        const onKey = (e: KeyboardEvent) => {
          if (e.key !== "Escape") return;
          setOpen(false);
          document.getElementById(`${id}-toggle`)?.focus();
        };
        window.addEventListener("keydown", onKey);
        return () => window.removeEventListener("keydown", onKey);
      }, [open, id]);
    
      const line = cn("absolute left-1/2 h-[1.5px] w-4 -translate-x-1/2 rounded-full transition-transform duration-300 motion-reduce:transition-none", dark ? "bg-white" : "bg-[#0b0b0c]");
    
      return (
        <>
          <button
            id={`${id}-toggle`}
            type="button"
            aria-expanded={open}
            aria-controls={`${id}-panel`}
            aria-label={open ? "Close menu" : "Open menu"}
            onClick={() => setOpen((o) => !o)}
            className={cn("relative grid size-9 shrink-0 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/10" : "hover:bg-black/5", className)}
          >
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 rotate-45" : "top-[13px]")} />
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 -rotate-45" : "top-[21px]")} />
          </button>
          <AnimatePresence>
            {open ? (
              <motion.div
                id={`${id}-panel`}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, transition: { duration: 0.12 } }}
                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                className={cn("absolute inset-x-2 top-full z-50 mt-2 origin-top rounded-2xl border p-2 shadow-xl", dark ? "border-white/10 bg-[#111113] text-white" : "border-black/[0.06] bg-white text-[#0b0b0c]", panelClassName)}
              >
                <ul>
                  {items.map((item) =>
                    item.items?.length ? (
                      <li key={item.label}>
                        <button
                          type="button"
                          aria-expanded={group === item.label}
                          onClick={() => setGroup((g) => (g === item.label ? null : item.label))}
                          className={cn("flex h-11 w-full cursor-pointer items-center justify-between rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}
                        >
                          {item.label}
                          <ChevronDown className={cn("size-4 opacity-60 transition-transform", group === item.label && "rotate-180")} aria-hidden="true" />
                        </button>
                        <div className={cn("grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none", group === item.label ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
                          <ul className="min-h-0 overflow-hidden" inert={group !== item.label}>
                            {item.items.map((link) => (
                              <li key={link.label}>
                                <a href={link.href} className={cn("flex items-center gap-2.5 rounded-lg py-2 pr-3 pl-6 text-[14px] outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "text-white/75 hover:text-white" : "text-[#52525b] hover:text-[#0b0b0c]")}>
                                  {link.icon ? <span aria-hidden="true" className="opacity-70 [&_svg]:size-4">{link.icon}</span> : null}
                                  {link.label}
                                </a>
                              </li>
                            ))}
                          </ul>
                        </div>
                      </li>
                    ) : (
                      <li key={item.label}>
                        <a href={item.href ?? "#"} className={cn("flex h-11 items-center rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}>
                          {item.label}
                        </a>
                      </li>
                    ),
                  )}
                </ul>
                {actions.length ? (
                  <div className={cn("mt-2 grid gap-2 border-t pt-3", dark ? "border-white/10" : "border-black/[0.06]")}>
                    {actions.map((a, i) => (
                      <a
                        key={a.label}
                        href={a.href}
                        className={cn(
                          "flex h-11 items-center justify-center rounded-xl text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring",
                          i === actions.length - 1 ? (dark ? "bg-white text-[#0b0b0c]" : "bg-[#0b0b0c] text-white") : dark ? "border border-white/15" : "border border-black/10",
                        )}
                      >
                        {a.label}
                      </a>
                    ))}
                  </div>
                ) : null}
              </motion.div>
            ) : null}
          </AnimatePresence>
        </>
      );
    }
    lib/nav/types.ts
    import type { ReactNode } from "react";
    
    /** A link in a menu or panel. */
    export interface NavLink {
      label: string;
      href: string;
      /** A short line under the label in rich menus. */
      description?: string;
      /** An icon shown beside the label in rich menus. */
      icon?: ReactNode;
    }
    
    /** A top-level navbar entry: a plain link, or a trigger with links under it. */
    export interface NavItem {
      label: string;
      /** Destination when the item is a plain link. */
      href?: string;
      /** Links shown in the item's menu; makes the item a menu trigger. */
      items?: NavLink[];
    }
    
    /** A call-to-action button. */
    export interface NavAction {
      label: string;
      href: string;
    }
    lib/nav/use-scroll-state.ts
    "use client";
    
    import { useEffect, useState, type RefObject } from "react";
    
    export interface ScrollState {
      /** Scroll offset in px. */
      y: number;
      /** Direction of the last meaningful scroll. */
      direction: "up" | "down";
      /** Share of the scrollable distance covered, 0–1. */
      progress: number;
    }
    
    /**
     * Scroll position, direction and progress of the window, or of `container` when given (handy for
     * navbars inside a scrolling panel). Updates once per frame at most.
     */
    export function useScrollState(container?: RefObject<HTMLElement | null>, threshold = 4): ScrollState {
      const [state, setState] = useState<ScrollState>({ y: 0, direction: "up", progress: 0 });
      useEffect(() => {
        const el = container?.current ?? null;
        const target: HTMLElement | Window = el ?? window;
        let last = 0;
        let direction: ScrollState["direction"] = "up";
        let frame = 0;
        const read = () => {
          frame = 0;
          const y = el ? el.scrollTop : window.scrollY;
          const max = el ? el.scrollHeight - el.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
          // Small moves keep the last direction; tracked here, not in the updater, which may run twice.
          if (Math.abs(y - last) >= threshold) {
            direction = y > last ? "down" : "up";
            last = y;
          }
          setState({ y, direction, progress: max > 0 ? Math.min(1, y / max) : 0 });
        };
        const onScroll = () => {
          if (!frame) frame = requestAnimationFrame(read);
        };
        target.addEventListener("scroll", onScroll, { passive: true });
        read();
        return () => {
          target.removeEventListener("scroll", onScroll);
          cancelAnimationFrame(frame);
        };
      }, [container, threshold]);
      return state;
    }
  3. Copy the source

    components/ui/gather-nav/gather-nav.tsx
    "use client";
    
    import { useId, useState, type ReactNode, type RefObject } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { Search } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { MobileMenu } from "@/lib/nav/mobile-menu";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    import { useScrollState } from "@/lib/nav/use-scroll-state";
    
    export interface GatherNavProps {
      /** Logo or wordmark. */
      brand: ReactNode;
      /** Links; they gather into a frosted pill once the page scrolls. */
      items: NavItem[];
      /** The filled call to action. */
      cta?: NavAction;
      /** Shows a search button and calls this when it is pressed. */
      onSearch?: () => void;
      /** Accessible name for the search button. @default "Search" */
      searchLabel?: string;
      /** Scroll container to watch; the window when left out. */
      scrollContainer?: RefObject<HTMLElement | null>;
      /** Scroll offset in px at which the bar gathers; 0 keeps it gathered. @default 16 */
      gatherAfter?: number;
      /** Accessible name for the navigation. @default "Main" */
      label?: string;
      /** Classes for the header. */
      className?: string;
    }
    
    const EASE = [0.22, 1, 0.36, 1] as const;
    
    /**
     * A plain white bar with a hairline that, once the page scrolls, melts away: its background fades,
     * the links draw together into a frosted pill, and the brand, search and call to action float on
     * their own over the content. A soft highlight glides between the links on hover.
     */
    export function GatherNav({ brand, items, cta, onSearch, searchLabel = "Search", scrollContainer, gatherAfter = 16, label = "Main", className }: GatherNavProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const { y } = useScrollState(scrollContainer);
      const gathered = y >= gatherAfter;
      const [hover, setHover] = useState<string | null>(null);
      const fade = reduceMotion ? { duration: 0 } : { duration: 0.45, ease: EASE };
      const frost = "bg-white/75 shadow-[0_6px_24px_-10px_rgb(0_0_0/0.18),inset_0_0_0_1px_rgb(0_0_0/0.05)] backdrop-blur-xl backdrop-saturate-150";
    
      /** A frosted backing that appears only while gathered. */
      const backing = (cls: string) => (
        <motion.span
          aria-hidden="true"
          className={cn("absolute -z-10", frost, cls)}
          initial={false}
          animate={gathered ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduceMotion ? 1 : 0.94 }}
          transition={fade}
        />
      );
    
      return (
        <header className={cn("@container sticky top-0 z-40", className)}>
          <motion.div
            aria-hidden="true"
            className="absolute inset-0 border-b border-black/[0.07] bg-white"
            initial={false}
            animate={{ opacity: gathered ? 0 : 1 }}
            transition={fade}
          />
          <nav aria-label={label} className="relative mx-auto flex h-16 max-w-6xl items-center gap-2 px-5">
            <div className="relative isolate mr-auto flex items-center">
              {/* The wordmark keeps a soft haze around it so it stays readable over busy pages. */}
              {backing("-inset-x-3 -inset-y-1.5 rounded-full !bg-white/60 !shadow-none blur-[2px]")}
              {brand}
            </div>
    
            <ul className="relative isolate hidden items-center p-1 @2xl:flex" onPointerLeave={() => setHover(null)}>
              {backing("inset-0 rounded-full")}
              {items.map((item) => (
                <li key={item.label} className="relative">
                  {hover === item.label ? (
                    <motion.span layoutId={`${id}-hover`} aria-hidden="true" className="absolute inset-0 rounded-full bg-black/[0.05]" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 38 }} />
                  ) : null}
                  <a
                    href={item.href ?? item.items?.[0]?.href ?? "#"}
                    onPointerEnter={() => setHover(item.label)}
                    onFocus={() => setHover(item.label)}
                    onBlur={() => setHover(null)}
                    className="after:absolute after:inset-x-0 after:-inset-y-0.5 relative flex h-9 items-center rounded-full px-3.5 text-[14px] whitespace-nowrap text-[#27272a] outline-none transition-colors hover:text-[#09090b] focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    {item.label}
                  </a>
                </li>
              ))}
            </ul>
    
            {onSearch ? (
              <button type="button" onClick={onSearch} aria-label={searchLabel} className="relative isolate hidden size-10 cursor-pointer place-items-center rounded-full text-[#71717a] outline-none transition-colors hover:text-[#09090b] focus-visible:ring-2 focus-visible:ring-ring @2xl:grid">
                {backing("inset-0 rounded-full")}
                <Search className="size-[18px]" aria-hidden="true" />
              </button>
            ) : null}
    
            {cta ? (
              <a href={cta.href} className="hidden h-10 items-center rounded-full bg-[#141414] px-4.5 text-[14px] font-medium whitespace-nowrap text-white shadow-[0_6px_16px_-8px_rgb(0_0_0/0.5)] outline-none transition-colors hover:bg-[#2a2a2a] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 @2xl:flex">
                {cta.label}
              </a>
            ) : null}
    
            <div className="@2xl:hidden">
              <MobileMenu items={items} actions={cta ? [cta] : []} className={cn("transition-[background-color,box-shadow] duration-300", gathered && frost)} />
            </div>
          </nav>
        </header>
      );
    }

Usage

import { GatherNav } from "@/components/ui/gather-nav/gather-nav";

<GatherNav brand={<Wordmark />} items={items} cta={{ label: "Add a tool", href: "/submit" }} onSearch={openSearch} />

API reference

PropTypeDefaultDescription
brand*ReactNode–Logo or wordmark.
items*NavItem[]–Links; they gather into a frosted pill once the page scrolls.
ctaNavAction–The filled call to action.
onSearch() => void–Shows a search button and calls this when it is pressed.
searchLabelstring"Search"Accessible name for the search button.
scrollContainerRefObject<HTMLElement | null>–Scroll container to watch; the window when left out.
gatherAfternumber16Scroll offset in px at which the bar gathers; 0 keeps it gathered.
labelstring"Main"Accessible name for the navigation.
classNamestring–Classes for the header.
  • At rest it is a solid white bar with a hairline. Past `gatherAfter` px of scroll the bar fades out, the links gather into a frosted pill, and the search button and narrow-layout menu button get frosted backings of their own.
  • The frosting uses `backdrop-filter`, so busy content (photos, dithered art) shows through softly blurred.
  • Pass `scrollContainer` when it lives in a scrolling panel; `gatherAfter={0}` gathers it from the start.

Accessibility

  • A labelled `<nav>`; the search control is a real button with an accessible name (`searchLabel`).
  • The frosted backings and hover highlight are decorative; focus rings stay on every link.
  • Under reduced motion the bar switches between its two looks without scaling.