Navbars & Headersv0.3.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand 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.tsimport 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; }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
| Prop | Type | Default | Description |
|---|---|---|---|
brand* | ReactNode | – | Logo or wordmark. |
items* | NavItem[] | – | Links; they gather into a frosted pill once the page scrolls. |
cta | NavAction | – | The filled call to action. |
onSearch | () => void | – | Shows a search button and calls this when it is pressed. |
searchLabel | string | "Search" | Accessible name for the search button. |
scrollContainer | RefObject<HTMLElement | null> | – | Scroll container to watch; the window when left out. |
gatherAfter | number | 16 | Scroll offset in px at which the bar gathers; 0 keeps it gathered. |
label | string | "Main" | Accessible name for the navigation. |
className | string | – | 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.