Navbars & Headersv0.3.0
Scroll-Aware Nav
Clear over the hero, frosted on scroll, hides going down, with progress.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/scroll-aware-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-menu.ts"use client"; import { useEffect, useRef, useState, type RefObject } from "react"; /** * Open/close state for a navbar's menus: one menu open at a time, opened by click or by hover with a * short intent delay, closed by Escape (focus returns to its trigger), an outside click, or the * pointer leaving the navbar. */ export function useMenu(root: RefObject<HTMLElement | null>) { const [open, setOpen] = useState<string | null>(null); const timer = useRef(0); const triggers = useRef(new Map<string, HTMLElement>()); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; triggers.current.get(open)?.focus(); setOpen(null); }; const onDown = (e: PointerEvent) => { if (root.current && !root.current.contains(e.target as Node)) setOpen(null); }; window.addEventListener("keydown", onKey); window.addEventListener("pointerdown", onDown); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("pointerdown", onDown); }; }, [open, root]); useEffect(() => () => window.clearTimeout(timer.current), []); const hoverOpen = (id: string) => { window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setOpen(id), open ? 0 : 90); }; const hoverClose = () => { window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setOpen(null), 160); }; const cancelClose = () => window.clearTimeout(timer.current); /** Props for a menu trigger button. */ const triggerProps = (id: string, panelId: string) => ({ ref: (el: HTMLElement | null) => { if (el) triggers.current.set(id, el); else triggers.current.delete(id); }, "aria-expanded": open === id, "aria-controls": panelId, onClick: () => setOpen((o) => (o === id ? null : id)), onPointerEnter: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverOpen(id), onPointerLeave: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverClose(), }); /** Props for a menu panel, so moving onto it keeps it open. */ const panelProps = { onPointerEnter: cancelClose, onPointerLeave: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverClose(), }; return { open, setOpen, triggerProps, panelProps, close: () => setOpen(null) }; }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/scroll-aware-nav/scroll-aware-nav.tsx"use client"; import { useId, useRef, type ReactNode, type RefObject } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { MobileMenu } from "@/lib/nav/mobile-menu"; import type { NavAction, NavItem } from "@/lib/nav/types"; import { useMenu } from "@/lib/nav/use-menu"; import { useScrollState } from "@/lib/nav/use-scroll-state"; export interface ScrollAwareNavProps { /** Logo and name. */ brand: ReactNode; /** Top-level entries; entries with `items` open a dropdown. */ items: NavItem[]; /** Actions on the right; the last one is the filled button. */ actions?: NavAction[]; /** Scroll container to watch; the window when left out. */ scrollContainer?: RefObject<HTMLElement | null>; /** Scroll offset in px after which the bar turns solid. @default 24 */ solidAfter?: number; /** Hide the bar while scrolling down, show it again on the way up. @default true */ hideOnScroll?: boolean; /** Show a reading progress line along the bottom edge. @default true */ progress?: boolean; /** Color of the progress line. @default "var(--primary)" */ accent?: string; /** Accessible name for the navigation. @default "Main" */ label?: string; /** Classes for the header. */ className?: string; } /** * A bar that stays out of the way: see-through over the hero, frosted with a hairline once the page * scrolls, tucked away while you read down and back the moment you scroll up, with a thin progress * line along its bottom edge. */ export function ScrollAwareNav({ brand, items, actions = [], scrollContainer, solidAfter = 24, hideOnScroll = true, progress = true, accent = "var(--primary)", label = "Main", className }: ScrollAwareNavProps) { const id = useId(); const reduceMotion = useReducedMotion(); const root = useRef<HTMLElement>(null); const menu = useMenu(root); const scroll = useScrollState(scrollContainer); const solid = scroll.y > solidAfter; const hidden = hideOnScroll && scroll.direction === "down" && scroll.y > 160 && !menu.open; const primary = actions.at(-1); return ( <motion.header ref={root} className={cn("@container pointer-events-none sticky top-0 z-40", className)} initial={false} animate={{ y: hidden ? "-100%" : "0%" }} transition={reduceMotion ? { duration: 0 } : { duration: 0.32, ease: [0.22, 1, 0.36, 1] }} > {/* The header keeps its 72px in the layout and only looks shorter when solid: the bar is 56px (invisible until solid) and the row moves up 8px. Changing its real height moved the page, and scroll anchoring then fought the threshold, so the header flickered just past it. */} <div className={cn( "pointer-events-auto absolute inset-x-0 top-0 -z-10 h-14 border-b transition-[background-color,border-color,box-shadow,backdrop-filter] duration-300 motion-reduce:transition-none", solid ? "border-black/[0.07] bg-white/80 shadow-[0_8px_24px_-18px_rgb(0_0_0/0.35)] backdrop-blur-xl" : "border-transparent bg-transparent", )} aria-hidden="true" /> <nav aria-label={label} className={cn("relative mx-auto flex h-[72px] max-w-6xl items-center gap-2 px-4 transition-transform duration-300 *:pointer-events-auto motion-reduce:transition-none", solid && "-translate-y-2")}> <div className="mr-6">{brand}</div> <ul className="hidden items-center gap-0.5 @2xl:flex"> {items.map((item) => { const panelId = `${id}-${item.label}`; if (!item.items?.length) { return ( <li key={item.label}> <a href={item.href ?? "#"} className="flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 whitespace-nowrap items-center rounded-md px-3 text-[14px] font-medium text-[#3f3f46] outline-none transition-colors hover:text-[#0b0b0c] focus-visible:ring-2 focus-visible:ring-ring"> {item.label} </a> </li> ); } const open = menu.open === item.label; return ( <li key={item.label} className="relative"> <button type="button" {...menu.triggerProps(item.label, panelId)} className="flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 whitespace-nowrap cursor-pointer items-center gap-1 rounded-md px-3 text-[14px] font-medium text-[#3f3f46] outline-none transition-colors hover:text-[#0b0b0c] focus-visible:ring-2 focus-visible:ring-ring"> {item.label} <ChevronDown className={cn("size-3.5 opacity-60 transition-transform duration-200", open && "rotate-180")} aria-hidden="true" /> </button> <AnimatePresence> {open ? ( <motion.div id={panelId} {...menu.panelProps} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }} className="absolute top-full left-0 z-50 mt-2 w-72 rounded-xl border border-black/[0.07] bg-white p-1.5 shadow-xl" > <ul> {item.items.map((l) => ( <li key={l.label}> <a href={l.href} onClick={menu.close} className="flex items-start gap-3 rounded-lg px-3 py-2.5 outline-none hover:bg-black/[0.04] focus-visible:bg-black/[0.04]"> {l.icon ? <span aria-hidden="true" className="mt-0.5 text-[#71717a] [&_svg]:size-4">{l.icon}</span> : null} <span> <span className="block text-[14px] font-medium text-[#18181b]">{l.label}</span> {l.description ? <span className="block text-[12.5px] text-[#71717a]">{l.description}</span> : null} </span> </a> </li> ))} </ul> </motion.div> ) : null} </AnimatePresence> </li> ); })} </ul> <div className="ml-auto hidden items-center gap-1 @2xl:flex"> {actions.slice(0, -1).map((a) => ( <a key={a.label} href={a.href} className="flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 whitespace-nowrap items-center rounded-md px-3 text-[14px] font-medium text-[#3f3f46] outline-none hover:text-[#0b0b0c] focus-visible:ring-2 focus-visible:ring-ring"> {a.label} </a> ))} {primary ? ( <a href={primary.href} className="flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 whitespace-nowrap items-center rounded-lg bg-[#0b0b0c] px-4 text-[14px] font-medium text-white outline-none transition-colors hover:bg-[#27272a] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"> {primary.label} </a> ) : null} </div> <div className="ml-auto @2xl:hidden"> {/* The panel hangs from the nav's bottom edge, which sits 8px below the solid bar. */} <MobileMenu items={items} actions={actions} panelClassName={solid ? "mt-0" : undefined} /> </div> </nav> {progress ? ( <div aria-hidden="true" className={cn("absolute inset-x-0 top-[54px] h-[2px] origin-left transition-opacity duration-300", solid ? "opacity-100" : "opacity-0")} style={{ background: accent, transform: `scaleX(${scroll.progress})` }} /> ) : null} </motion.header> ); }
Usage
import { ScrollAwareNav } from "@/components/ui/scroll-aware-nav/scroll-aware-nav";
<ScrollAwareNav brand={<Logo />} items={items} actions={actions} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
brand* | ReactNode | – | Logo and name. |
items* | NavItem[] | – | Top-level entries; entries with `items` open a dropdown. |
actions | NavAction[] | – | Actions on the right; the last one is the filled button. |
scrollContainer | RefObject<HTMLElement | null> | – | Scroll container to watch; the window when left out. |
solidAfter | number | 24 | Scroll offset in px after which the bar turns solid. |
hideOnScroll | boolean | true | Hide the bar while scrolling down, show it again on the way up. |
progress | boolean | true | Show a reading progress line along the bottom edge. |
accent | string | "var(--primary)" | Color of the progress line. |
label | string | "Main" | Accessible name for the navigation. |
className | string | – | Classes for the header. |
- See-through until `solidAfter` px, then frosted with a hairline and a shorter bar. The bar only looks shorter: the header keeps its height in the layout, so the page never moves under it, and the strip below the bar lets clicks through.
- `hideOnScroll={false}` keeps it pinned; `progress={false}` drops the line.
- Pass `scrollContainer` when it lives in a scrolling panel.
Accessibility
- It never hides while a menu is open, and shows again on any upward scroll.
- The progress line is decorative (`aria-hidden`).
- Motion is instant under reduced motion.