Navbars & Headersv0.3.0
Catalog Menu
A two-column mega menu with icons, a gliding highlight and a spotlight card.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/catalog-menu.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) }; }Copy the source
components/ui/catalog-menu/catalog-menu.tsx"use client"; import { useId, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { ArrowRight, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { MobileMenu } from "@/lib/nav/mobile-menu"; import type { NavAction, NavItem, NavLink } from "@/lib/nav/types"; import { useMenu } from "@/lib/nav/use-menu"; export interface CatalogGroup { title: string; links: NavLink[]; } export interface CatalogMenuItem extends NavItem { /** Columns for this item's mega menu; overrides `items`. */ groups?: CatalogGroup[]; /** A highlighted card along the bottom of the menu. */ spotlight?: NavLink; } export interface CatalogMenuProps { /** Logo and name. */ brand: ReactNode; /** Entries; those with `groups` or `items` open the catalog. */ items: CatalogMenuItem[]; /** Actions on the right; the last one is filled. */ actions?: NavAction[]; /** Accessible name for the navigation. @default "Main" */ label?: string; /** Classes for the header. */ className?: string; } /** * A navbar with a catalog-style mega menu: columns of links with icons and one-line descriptions, a * soft highlight that glides to whichever link you point at, and a spotlight card along the bottom. */ export function CatalogMenu({ brand, items, actions = [], label = "Main", className }: CatalogMenuProps) { const id = useId(); const reduceMotion = useReducedMotion(); const root = useRef<HTMLElement>(null); const menu = useMenu(root); const [hover, setHover] = useState<string | null>(null); const openItem = items.find((i) => i.label === menu.open); const groups = openItem?.groups ?? (openItem?.items ? [{ title: openItem.label, links: openItem.items }] : []); return ( <header ref={root} className={cn("@container sticky top-0 z-40 bg-white/85 backdrop-blur-xl", className)}> <nav aria-label={label} className="relative mx-auto flex h-14 max-w-6xl items-center gap-1 px-4"> <div className="mr-4">{brand}</div> <ul className="hidden items-center @2xl:flex"> {items.map((item) => item.groups?.length || item.items?.length ? ( <li key={item.label}> <button type="button" {...menu.triggerProps(item.label, `${id}-panel`)} className={cn("flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 cursor-pointer items-center gap-1 rounded-md px-3 text-[14px] text-[#3f3f46] outline-none hover:text-[#09090b] focus-visible:ring-2 focus-visible:ring-ring", menu.open === item.label && "bg-black/[0.05] text-[#09090b]")} > {item.label} <ChevronDown className={cn("size-3.5 opacity-60 transition-transform", menu.open === item.label && "rotate-180")} aria-hidden="true" /> </button> </li> ) : ( <li key={item.label}> <a href={item.href ?? "#"} className="flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 items-center rounded-md px-3 text-[14px] text-[#3f3f46] outline-none hover:text-[#09090b] focus-visible:ring-2 focus-visible:ring-ring"> {item.label} </a> </li> ), )} </ul> <div className="ml-auto hidden items-center gap-2 @2xl:flex"> {actions.map((a, i) => ( <a key={a.label} href={a.href} className={cn( "h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 items-center rounded-md px-3.5 text-[14px] whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring", // Middle actions wait for room; the first and last always show. i > 0 && i < actions.length - 1 ? "hidden @4xl:flex" : "flex", i === actions.length - 1 ? "border border-[#c2410c]/40 bg-[#fff7ed] font-medium text-[#9a3412] hover:bg-[#ffedd5]" : "text-[#3f3f46] hover:text-[#09090b]", )} > {a.label} </a> ))} </div> <div className="ml-auto @2xl:hidden"> <MobileMenu items={items.map((i) => ({ label: i.label, href: i.href, items: i.groups ? i.groups.flatMap((g) => g.links) : i.items }))} actions={actions} /> </div> <AnimatePresence> {openItem && groups.length ? ( <motion.div id={`${id}-panel`} key={openItem.label} {...menu.panelProps} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.99 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }} className="absolute top-full left-4 z-50 mt-1 w-[min(46rem,calc(100%-2rem))] origin-top-left rounded-xl border border-black/[0.08] bg-white p-3 shadow-2xl" onPointerLeave={() => setHover(null)} > <LayoutGroup id={`${id}-hover`}> <div className={cn("grid gap-4", groups.length > 2 ? "grid-cols-2 @4xl:grid-cols-3" : groups.length > 1 ? "grid-cols-2" : "grid-cols-1")}> {groups.map((group) => ( <div key={group.title}> <p className="px-2.5 pt-1 pb-2 text-[11px] font-medium tracking-wide text-[#a1a1aa] uppercase">{group.title}</p> <ul> {group.links.map((link) => ( <li key={link.label}> <a href={link.href} onClick={menu.close} onPointerEnter={() => setHover(link.label)} onFocus={() => setHover(link.label)} className="relative flex gap-3 rounded-lg px-2.5 py-2 outline-none" > {hover === link.label ? <motion.span layoutId="catalog-hover" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 38 }} className="absolute inset-0 rounded-lg bg-[#f4f4f5]" /> : null} {link.icon ? <span aria-hidden="true" className="relative mt-0.5 text-[#52525b] [&_svg]:size-[18px]">{link.icon}</span> : null} <span className="relative"> <span className="block text-[14px] font-medium text-[#09090b]">{link.label}</span> {link.description ? <span className="block text-[12.5px] text-[#71717a]">{link.description}</span> : null} </span> </a> </li> ))} </ul> </div> ))} </div> </LayoutGroup> {openItem.spotlight ? ( <a href={openItem.spotlight.href} onClick={menu.close} className="group mt-3 flex items-center gap-3 rounded-lg border border-dashed border-black/10 bg-[repeating-linear-gradient(135deg,#fafafa_0_6px,#f4f4f5_6px_7px)] px-3 py-3 outline-none focus-visible:ring-2 focus-visible:ring-ring" > {openItem.spotlight.icon ? <span aria-hidden="true" className="text-[#52525b] [&_svg]:size-[18px]">{openItem.spotlight.icon}</span> : null} <span className="flex-1"> <span className="block text-[14px] font-medium text-[#09090b]">{openItem.spotlight.label}</span> {openItem.spotlight.description ? <span className="block text-[12.5px] text-[#71717a]">{openItem.spotlight.description}</span> : null} </span> <ArrowRight className="size-4 text-[#71717a] transition-transform group-hover:translate-x-0.5" aria-hidden="true" /> </a> ) : null} </motion.div> ) : null} </AnimatePresence> </nav> </header> ); }
Usage
import { CatalogMenu } from "@/components/ui/catalog-menu/catalog-menu";
<CatalogMenu brand={<Logo />} items={[{ label: "Platform", groups, spotlight }]} actions={actions} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
brand* | ReactNode | – | Logo and name. |
items* | CatalogMenuItem[] | – | Entries; those with `groups` or `items` open the catalog. |
actions | NavAction[] | – | Actions on the right; the last one is filled. |
label | string | "Main" | Accessible name for the navigation. |
className | string | – | Classes for the header. |
- `groups` lays out columns; plain `items` fall back to one list.
- `spotlight` adds a striped card along the bottom, good for a launch or event.
- One highlight glides between the links you hover (shared layout animation).
Accessibility
- Group titles are headings for their columns of links.
- The hover highlight is decorative; focus rings stay visible on every link.
- Triggers expose `aria-expanded`; Escape closes and returns focus.