Navbars & Headersv0.3.0
Sheet Nav
A hamburger that morphs into a full-screen sheet with staggered links.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/sheet-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/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; }Copy the source
components/ui/sheet-nav/sheet-nav.tsx"use client"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ArrowUpRight, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import type { NavAction, NavItem, NavLink } from "@/lib/nav/types"; export interface SheetNavProps { /** Logo and name; it stays visible above the open sheet. */ brand: ReactNode; /** Big links in the sheet; entries with `items` expand in place. */ items: NavItem[]; /** Smaller links in the sheet's side column, e.g. contact or socials. */ aside?: NavLink[]; /** Heading above the side column. @default "Elsewhere" */ asideLabel?: string; /** Actions along the sheet's bottom edge; the last one is filled. */ actions?: NavAction[]; /** One action kept on the bar beside the menu button. */ cta?: NavAction; /** Sheet background. @default "#f3efe7" */ background?: string; /** Classes for the sheet, e.g. a height when the navbar lives in a panel. @default "h-dvh" */ sheetClassName?: string; /** 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 minimal bar whose "Menu" button grows a full sheet out of its corner, with oversized links * that rise in one after another, groups that expand in place, a side column and actions at the * bottom. Escape closes it and returns focus to the button. */ export function SheetNav({ brand, items, aside = [], asideLabel = "Elsewhere", actions = [], cta, background = "#f3efe7", sheetClassName = "h-dvh", label = "Main", className }: SheetNavProps) { const id = useId(); const reduceMotion = useReducedMotion(); const toggle = useRef<HTMLButtonElement>(null); const [open, setOpen] = useState(false); const [group, setGroup] = useState<string | null>(null); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; setOpen(false); toggle.current?.focus(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open]); const close = () => setOpen(false); const corner = "calc(100% - 52px) 32px"; const line = "absolute left-1/2 h-[1.5px] w-4 -translate-x-1/2 rounded-full bg-current transition-transform duration-300 motion-reduce:transition-none"; const rise = (i: number) => reduceMotion ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, } : { initial: { opacity: 0, y: 28 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.55, ease: EASE, delay: 0.12 + i * 0.05 }, }; return ( <header className={cn("@container sticky top-0 z-40", className)}> <div className={cn("relative z-10 transition-[background-color,backdrop-filter] duration-300", !open && "bg-white/70 backdrop-blur-xl")}> <div className="mx-auto flex h-16 max-w-6xl items-center gap-2 px-4"> <div className="mr-auto">{brand}</div> {cta ? ( <a href={cta.href} className={cn( "hidden h-10 items-center rounded-full border border-black/10 px-4 text-[14px] font-medium text-[#0b0b0c] outline-none transition-opacity hover:bg-black/[0.04] focus-visible:ring-2 focus-visible:ring-ring @md:flex", open && "pointer-events-none opacity-0", )} tabIndex={open ? -1 : undefined} > {cta.label} </a> ) : null} <button ref={toggle} type="button" aria-expanded={open} aria-controls={`${id}-sheet`} onClick={() => setOpen((o) => !o)} className="flex h-10 cursor-pointer items-center gap-2.5 rounded-full bg-[#0b0b0c] pr-2 pl-4 text-[14px] font-medium text-white outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > <span className="relative h-5 overflow-hidden"> <span className={cn("flex flex-col transition-transform duration-300 motion-reduce:transition-none", open && "-translate-y-1/2")}> <span className="h-5 leading-5">Menu</span> <span className="h-5 leading-5">Close</span> </span> </span> <span aria-hidden="true" className="relative grid size-7 place-items-center rounded-full bg-white/15"> <span className={cn(line, open ? "top-1/2 rotate-45" : "top-[11px]")} /> <span className={cn(line, open ? "top-1/2 -rotate-45" : "top-[16px]")} /> </span> </button> </div> </div> <AnimatePresence> {open ? ( <motion.nav id={`${id}-sheet`} aria-label={label} className={cn("absolute inset-x-0 top-0 flex flex-col overflow-y-auto text-[#0b0b0c]", sheetClassName)} style={{ background }} initial={reduceMotion ? { opacity: 0 } : { clipPath: `circle(0% at ${corner})` }} animate={reduceMotion ? { opacity: 1 } : { clipPath: `circle(150% at ${corner})` }} exit={ reduceMotion ? { opacity: 0 } : { clipPath: `circle(0% at ${corner})`, transition: { duration: 0.45, ease: [0.64, 0, 0.78, 0] }, } } transition={{ duration: 0.7, ease: EASE }} > <div className="mx-auto grid w-full max-w-6xl flex-1 gap-10 px-4 pt-24 pb-6 @2xl:grid-cols-[1fr_220px]"> <ul className="self-start"> {items.map((item, i) => { const expandable = !!item.items?.length; const expanded = group === item.label; const big = "group flex w-full items-center justify-between gap-4 border-b border-black/10 py-3 text-left text-[clamp(1.75rem,6cqi,3.25rem)] leading-none font-semibold tracking-[-0.03em] outline-none focus-visible:ring-2 focus-visible:ring-ring"; return ( <motion.li key={item.label} {...rise(i)}> {expandable ? ( <> <button type="button" aria-expanded={expanded} onClick={() => setGroup((g) => (g === item.label ? null : item.label))} className={cn(big, "cursor-pointer")}> <span className="transition-transform duration-300 group-hover:translate-x-1.5 motion-reduce:transition-none">{item.label}</span> <Plus className={cn("size-6 shrink-0 opacity-50 transition-transform duration-300", expanded && "rotate-45")} aria-hidden="true" /> </button> <div className={cn("grid transition-[grid-template-rows] duration-400 ease-out motion-reduce:transition-none", expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}> <ul className="grid min-h-0 gap-x-6 overflow-hidden @xl:grid-cols-2" inert={!expanded}> {item.items!.map((l) => ( <li key={l.label}> <a href={l.href} onClick={close} className="flex items-baseline gap-3 rounded-md py-2.5 outline-none first:mt-3 hover:opacity-70 focus-visible:ring-2 focus-visible:ring-ring"> <span className="text-[17px] font-medium">{l.label}</span> {l.description ? <span className="truncate text-[13px] text-black/50">{l.description}</span> : null} </a> </li> ))} </ul> </div> </> ) : ( <a href={item.href ?? "#"} onClick={close} className={big}> <span className="transition-transform duration-300 group-hover:translate-x-1.5 motion-reduce:transition-none">{item.label}</span> <ArrowUpRight className="size-6 shrink-0 opacity-0 transition-opacity group-hover:opacity-50" aria-hidden="true" /> </a> )} </motion.li> ); })} </ul> {aside.length ? ( <motion.div {...rise(items.length)} className="self-start @2xl:pt-3"> <p className="text-[12px] font-medium tracking-[0.08em] text-black/45 uppercase">{asideLabel}</p> <ul className="mt-3 grid gap-1"> {aside.map((l) => ( <li key={l.label}> <a href={l.href} onClick={close} className="group inline-flex items-center gap-1.5 rounded-md py-1 text-[15px] outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"> {l.label} <ArrowUpRight className="size-3.5 opacity-40 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" aria-hidden="true" /> </a> </li> ))} </ul> </motion.div> ) : null} </div> {actions.length ? ( <motion.div {...rise(items.length + 1)} className="mx-auto flex w-full max-w-6xl flex-wrap gap-2 px-4 pb-6"> {actions.map((a, i) => ( <a key={a.label} href={a.href} onClick={close} className={cn( "flex h-12 flex-1 items-center justify-center rounded-full text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 @md:flex-none @md:px-7", i === actions.length - 1 ? "bg-[#0b0b0c] text-white hover:bg-[#27272a]" : "border border-black/15 hover:bg-black/[0.04]", )} > {a.label} </a> ))} </motion.div> ) : null} </motion.nav> ) : null} </AnimatePresence> </header> ); }
Usage
import { SheetNav } from "@/components/ui/sheet-nav/sheet-nav";
<SheetNav brand={<Logo />} items={items} aside={contact} actions={actions} cta={{ label: "Book a demo", href: "/demo" }} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
brand* | ReactNode | – | Logo and name; it stays visible above the open sheet. |
items* | NavItem[] | – | Big links in the sheet; entries with `items` expand in place. |
aside | NavLink[] | – | Smaller links in the sheet's side column, e.g. contact or socials. |
asideLabel | string | "Elsewhere" | Heading above the side column. |
actions | NavAction[] | – | Actions along the sheet's bottom edge; the last one is filled. |
cta | NavAction | – | One action kept on the bar beside the menu button. |
background | string | "#f3efe7" | Sheet background. |
sheetClassName | string | "h-dvh" | Classes for the sheet, e.g. a height when the navbar lives in a panel. |
label | string | "Main" | Accessible name for the navigation. |
className | string | – | Classes for the header. |
- The sheet grows out of the button with a clip-path circle; links rise in one after another.
- It covers the screen (`h-dvh`) by default; pass `sheetClassName` to size it inside a panel.
- Works the same at every width, which makes it a good fit for portfolio and studio sites.
Accessibility
- The Menu button reports `aria-expanded` and controls the sheet, a labelled `<nav>`.
- Escape closes the sheet and returns focus to the button; groups expand with `aria-expanded` and stay inert while closed.
- Under reduced motion the sheet fades instead of growing out of the corner.