Mobile drawer nav
Hamburger header with a focus-trapped left drawer, nested categories, search, and account rows.
blockdraweranysearchcart-count
$
npx shadcn@latest add @storekit/nav-drawernav-drawer
Live and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
$
npx shadcn@latest add @storekit/nav-drawer$
pnpm dlx shadcn@latest add @storekit/nav-drawer$
yarn dlx shadcn@latest add @storekit/nav-drawer$
bunx --bun shadcn@latest add @storekit/nav-drawerSource
components/storekit/nav/nav-drawer.tsx
267 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ChevronDown, ChevronRight, Menu, Search, ShoppingBag, User, X } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Mobile header with a left drawer. The drawer is a modal dialog: backdrop,
page scroll lock, focus trap, Escape closes, focus returns to the menu
button. Categories are nested accordions. Set `contained` to position the
drawer inside the nearest relative parent instead of the viewport. */
type Category = { label: string; href: string; children?: { label: string; href: string }[] }
const ring =
"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])'
export function NavDrawer({
brandName = "Morrow",
markSrc,
categories = [
{
label: "Powders",
href: "#powders",
children: [
{ label: "Daily Greens", href: "#daily-greens" },
{ label: "Electrolytes", href: "#electrolytes" },
{ label: "Protein", href: "#protein" },
],
},
{
label: "Capsules and gummies",
href: "#capsules-gummies",
children: [
{ label: "Focus", href: "#focus" },
{ label: "Magnesium Night", href: "#magnesium-night" },
],
},
{
label: "Drinks",
href: "#drinks",
children: [
{ label: "Sparkling Recovery", href: "#sparkling-recovery" },
],
},
{ label: "Lab results", href: "#lab-results" },
{ label: "Journal", href: "#journal" },
],
cartCount = 0,
onCart,
contained = false,
className,
}: {
brandName?: string
markSrc?: string
categories?: Category[]
cartCount?: number
onCart?: () => void
/** Position the drawer absolutely inside the nearest relative parent. */
contained?: boolean
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("nav", "nav-drawer")
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
const drawerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = "hidden"
const raf = requestAnimationFrame(() => drawerRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus())
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
setOpen(false)
return
}
if (e.key !== "Tab" || !drawerRef.current) return
const items = Array.from(drawerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))
if (!items.length) return
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener("keydown", onKey)
const trigger = triggerRef.current
return () => {
cancelAnimationFrame(raf)
document.body.style.overflow = prev
document.removeEventListener("keydown", onKey)
// preventScroll: a hash link that closed the drawer has already scrolled the page.
trigger?.focus({ preventScroll: true })
}
}, [open])
const pos = contained ? "absolute" : "fixed"
const iconBtn = cn("grid h-11 w-11 place-items-center rounded-sk-btn text-sk-ink transition-colors duration-200 hover:bg-sk-ink/[0.05] active:scale-[0.97]", ring)
const ease = [0.16, 1, 0.3, 1] as const
const drawerEase = [0.32, 0.72, 0, 1] as const
return (
<>
<header ref={sk.ref} {...sk.attrs} className={cn("sticky top-0 z-30 border-b border-sk-line bg-sk-paper", className)}>
<div className="grid h-14 grid-cols-[auto_1fr_auto] items-center px-2">
<button ref={triggerRef} type="button" aria-label="Open menu" aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen(true)} className={iconBtn}>
<Menu size={20} aria-hidden />
</button>
<a href="/" className={cn("mx-auto flex min-h-11 min-w-0 items-center gap-2 rounded-sk-btn px-1", ring)}>
{markSrc ? <img src={markSrc} alt="" className="h-6 w-6 shrink-0 object-contain" /> : null}
<span className="truncate font-sk-display text-[15px] font-semibold tracking-[-0.02em] text-sk-ink">{brandName}</span>
</a>
<button
type="button"
aria-label={`Cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}
onClick={() => {
sk.emit("interact", "cart_open")
onCart?.()
}}
className={cn(iconBtn, "relative")}
>
<ShoppingBag size={20} aria-hidden />
{cartCount > 0 ? (
<span aria-hidden className="absolute right-1 top-1 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-sk-accent px-1 text-[10px] font-semibold leading-none text-sk-accent-ink">{cartCount}</span>
) : null}
</button>
</div>
</header>
<AnimatePresence>
{open ? (
<div className={cn(pos, "inset-0 z-50")}>
<motion.div
aria-hidden
onClick={() => setOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="absolute inset-0 bg-[rgb(0_0_0/0.45)]"
/>
<motion.div
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(e) => {
// Any link inside closes the drawer, so hash links do not leave it open with scroll locked.
if ((e.target as HTMLElement).closest("a[href]")) setOpen(false)
}}
initial={reduce ? { opacity: 0 } : { x: "-100%" }}
animate={reduce ? { opacity: 1 } : { x: 0 }}
exit={reduce ? { opacity: 0 } : { x: "-100%" }}
transition={{ duration: 0.36, ease: drawerEase }}
className="absolute inset-y-0 left-0 flex w-[86%] max-w-sm flex-col border-r border-sk-line bg-sk-paper shadow-[0_24px_60px_-24px_rgb(0_0_0/0.35)]"
>
<div className="flex h-14 items-center justify-between border-b border-sk-line pl-4 pr-2">
<h2 id={titleId} className="font-sk-display text-[15px] font-semibold tracking-[-0.02em] text-balance">Menu</h2>
<button type="button" aria-label="Close menu" onClick={() => setOpen(false)} className={iconBtn}>
<X size={20} aria-hidden />
</button>
</div>
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4">
<form role="search" onSubmit={(e) => e.preventDefault()} className="relative">
<Search size={16} aria-hidden className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-sk-muted" />
<input
type="search"
aria-label="Search products"
placeholder="Search products"
className="h-11 w-full rounded-sk-btn border border-sk-line bg-sk-card pl-10 pr-3.5 text-base text-sk-ink outline-none sm:text-sm transition-[border-color,box-shadow] duration-200 placeholder:text-sk-muted/70 focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15"
/>
</form>
<ul className="mt-4 divide-y divide-sk-line border-y border-sk-line">
{categories.map((c) => {
const isOpen = expanded === c.label
const panelId = `${titleId}-${c.href.replace(/\W/g, "")}`
return (
<li key={c.href}>
{c.children?.length ? (
<>
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setExpanded(isOpen ? null : c.label)}
className={cn("flex min-h-12 w-full items-center justify-between text-left text-[15px] font-medium text-sk-ink", ring)}
>
{c.label}
<ChevronDown size={16} aria-hidden className={cn("text-sk-muted transition-transform duration-200", isOpen && "rotate-180")} />
</button>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
id={panelId}
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease }}
className="overflow-hidden"
>
<ul className="pb-2">
{c.children.map((s) => (
<li key={s.href}>
<a href={s.href} className={cn("flex min-h-11 items-center rounded-sk-btn pl-3 text-sm text-sk-muted transition-colors duration-200 hover:text-sk-ink", ring)}>
{s.label}
</a>
</li>
))}
<li>
<a href={c.href} className={cn("flex min-h-11 items-center rounded-sk-btn pl-3 text-sm font-medium text-sk-accent", ring)}>
Shop all {c.label.toLowerCase()}
</a>
</li>
</ul>
</motion.div>
) : null}
</AnimatePresence>
</>
) : (
<a href={c.href} className={cn("flex min-h-12 items-center text-[15px] font-medium text-sk-ink", ring)}>
{c.label}
</a>
)}
</li>
)
})}
</ul>
</div>
<div className="grid gap-1 border-t border-sk-line p-2">
<a href="#account" className={cn("flex min-h-12 items-center gap-3 rounded-sk-btn px-2 text-sm text-sk-ink transition-colors duration-200 hover:bg-sk-ink/[0.04]", ring)}>
<User size={18} aria-hidden className="text-sk-muted" />
<span className="flex-1">Account</span>
<ChevronRight size={16} aria-hidden className="text-sk-muted" />
</a>
<a
href="#cart"
onClick={() => sk.emit("interact", "cart_open")}
className={cn("flex min-h-12 items-center gap-3 rounded-sk-btn px-2 text-sm text-sk-ink transition-colors duration-200 hover:bg-sk-ink/[0.04]", ring)}
>
<ShoppingBag size={18} aria-hidden className="text-sk-muted" />
<span className="flex-1">Cart</span>
<span className="font-sk-mono text-[11px] tabular-nums text-sk-muted">{cartCount} {cartCount === 1 ? "item" : "items"}</span>
</a>
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
</>
)
}
Usage
Import from "@/components/storekit/nav/nav-drawer" and pass your product data. The demo above exercises the full prop surface with sample values.

