{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "nav-drawer",
  "type": "registry:block",
  "title": "Mobile drawer nav",
  "description": "Hamburger header with a focus-trapped left drawer, nested categories, search, and account rows.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "nav",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "nav/nav-drawer.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { ChevronDown, ChevronRight, Menu, Search, ShoppingBag, User, X } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Mobile header with a left drawer. The drawer is a modal dialog: backdrop,\n   page scroll lock, focus trap, Escape closes, focus returns to the menu\n   button. Categories are nested accordions. Set `contained` to position the\n   drawer inside the nearest relative parent instead of the viewport. */\n\ntype Category = { label: string; href: string; children?: { label: string; href: string }[] }\n\nconst ring =\n  \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])'\n\nexport function NavDrawer({\n  brandName = \"Morrow\",\n  markSrc,\n  categories = [\n    {\n      label: \"Powders\",\n      href: \"#powders\",\n      children: [\n        { label: \"Daily Greens\", href: \"#daily-greens\" },\n        { label: \"Electrolytes\", href: \"#electrolytes\" },\n        { label: \"Protein\", href: \"#protein\" },\n      ],\n    },\n    {\n      label: \"Capsules and gummies\",\n      href: \"#capsules-gummies\",\n      children: [\n        { label: \"Focus\", href: \"#focus\" },\n        { label: \"Magnesium Night\", href: \"#magnesium-night\" },\n      ],\n    },\n    {\n      label: \"Drinks\",\n      href: \"#drinks\",\n      children: [\n        { label: \"Sparkling Recovery\", href: \"#sparkling-recovery\" },\n      ],\n    },\n    { label: \"Lab results\", href: \"#lab-results\" },\n    { label: \"Journal\", href: \"#journal\" },\n  ],\n  cartCount = 0,\n  onCart,\n  contained = false,\n  className,\n}: {\n  brandName?: string\n  markSrc?: string\n  categories?: Category[]\n  cartCount?: number\n  onCart?: () => void\n  /** Position the drawer absolutely inside the nearest relative parent. */\n  contained?: boolean\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"nav\", \"nav-drawer\")\n  const [open, setOpen] = useState(false)\n  const [expanded, setExpanded] = useState<string | null>(null)\n  const drawerRef = useRef<HTMLDivElement>(null)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const titleId = useId()\n\n  useEffect(() => {\n    if (!open) return\n    const prev = document.body.style.overflow\n    document.body.style.overflow = \"hidden\"\n    const raf = requestAnimationFrame(() => drawerRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus())\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault()\n        setOpen(false)\n        return\n      }\n      if (e.key !== \"Tab\" || !drawerRef.current) return\n      const items = Array.from(drawerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))\n      if (!items.length) return\n      const first = items[0]\n      const last = items[items.length - 1]\n      if (e.shiftKey && document.activeElement === first) {\n        e.preventDefault()\n        last.focus()\n      } else if (!e.shiftKey && document.activeElement === last) {\n        e.preventDefault()\n        first.focus()\n      }\n    }\n    document.addEventListener(\"keydown\", onKey)\n    const trigger = triggerRef.current\n    return () => {\n      cancelAnimationFrame(raf)\n      document.body.style.overflow = prev\n      document.removeEventListener(\"keydown\", onKey)\n      // preventScroll: a hash link that closed the drawer has already scrolled the page.\n      trigger?.focus({ preventScroll: true })\n    }\n  }, [open])\n\n  const pos = contained ? \"absolute\" : \"fixed\"\n  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)\n  const ease = [0.16, 1, 0.3, 1] as const\n  const drawerEase = [0.32, 0.72, 0, 1] as const\n\n  return (\n    <>\n      <header ref={sk.ref} {...sk.attrs} className={cn(\"sticky top-0 z-30 border-b border-sk-line bg-sk-paper\", className)}>\n        <div className=\"grid h-14 grid-cols-[auto_1fr_auto] items-center px-2\">\n          <button ref={triggerRef} type=\"button\" aria-label=\"Open menu\" aria-expanded={open} aria-haspopup=\"dialog\" onClick={() => setOpen(true)} className={iconBtn}>\n            <Menu size={20} aria-hidden />\n          </button>\n          <a href=\"/\" className={cn(\"mx-auto flex min-h-11 min-w-0 items-center gap-2 rounded-sk-btn px-1\", ring)}>\n            {markSrc ? <img src={markSrc} alt=\"\" className=\"h-6 w-6 shrink-0 object-contain\" /> : null}\n            <span className=\"truncate font-sk-display text-[15px] font-semibold tracking-[-0.02em] text-sk-ink\">{brandName}</span>\n          </a>\n          <button\n            type=\"button\"\n            aria-label={`Cart, ${cartCount} ${cartCount === 1 ? \"item\" : \"items\"}`}\n            onClick={() => {\n              sk.emit(\"interact\", \"cart_open\")\n              onCart?.()\n            }}\n            className={cn(iconBtn, \"relative\")}\n          >\n            <ShoppingBag size={20} aria-hidden />\n            {cartCount > 0 ? (\n              <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>\n            ) : null}\n          </button>\n        </div>\n      </header>\n\n      <AnimatePresence>\n        {open ? (\n          <div className={cn(pos, \"inset-0 z-50\")}>\n            <motion.div\n              aria-hidden\n              onClick={() => setOpen(false)}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.25 }}\n              className=\"absolute inset-0 bg-[rgb(0_0_0/0.45)]\"\n            />\n            <motion.div\n              ref={drawerRef}\n              role=\"dialog\"\n              aria-modal=\"true\"\n              aria-labelledby={titleId}\n              onClick={(e) => {\n                // Any link inside closes the drawer, so hash links do not leave it open with scroll locked.\n                if ((e.target as HTMLElement).closest(\"a[href]\")) setOpen(false)\n              }}\n              initial={reduce ? { opacity: 0 } : { x: \"-100%\" }}\n              animate={reduce ? { opacity: 1 } : { x: 0 }}\n              exit={reduce ? { opacity: 0 } : { x: \"-100%\" }}\n              transition={{ duration: 0.36, ease: drawerEase }}\n              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)]\"\n            >\n              <div className=\"flex h-14 items-center justify-between border-b border-sk-line pl-4 pr-2\">\n                <h2 id={titleId} className=\"font-sk-display text-[15px] font-semibold tracking-[-0.02em] text-balance\">Menu</h2>\n                <button type=\"button\" aria-label=\"Close menu\" onClick={() => setOpen(false)} className={iconBtn}>\n                  <X size={20} aria-hidden />\n                </button>\n              </div>\n\n              <div className=\"flex-1 overflow-y-auto overscroll-contain px-4 py-4\">\n                <form role=\"search\" onSubmit={(e) => e.preventDefault()} className=\"relative\">\n                  <Search size={16} aria-hidden className=\"pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-sk-muted\" />\n                  <input\n                    type=\"search\"\n                    aria-label=\"Search products\"\n                    placeholder=\"Search products\"\n                    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\"\n                  />\n                </form>\n\n                <ul className=\"mt-4 divide-y divide-sk-line border-y border-sk-line\">\n                  {categories.map((c) => {\n                    const isOpen = expanded === c.label\n                    const panelId = `${titleId}-${c.href.replace(/\\W/g, \"\")}`\n                    return (\n                      <li key={c.href}>\n                        {c.children?.length ? (\n                          <>\n                            <button\n                              type=\"button\"\n                              aria-expanded={isOpen}\n                              aria-controls={panelId}\n                              onClick={() => setExpanded(isOpen ? null : c.label)}\n                              className={cn(\"flex min-h-12 w-full items-center justify-between text-left text-[15px] font-medium text-sk-ink\", ring)}\n                            >\n                              {c.label}\n                              <ChevronDown size={16} aria-hidden className={cn(\"text-sk-muted transition-transform duration-200\", isOpen && \"rotate-180\")} />\n                            </button>\n                            <AnimatePresence initial={false}>\n                              {isOpen ? (\n                                <motion.div\n                                  id={panelId}\n                                  initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                                  animate={reduce ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n                                  exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                                  transition={{ duration: 0.3, ease }}\n                                  className=\"overflow-hidden\"\n                                >\n                                  <ul className=\"pb-2\">\n                                    {c.children.map((s) => (\n                                      <li key={s.href}>\n                                        <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)}>\n                                          {s.label}\n                                        </a>\n                                      </li>\n                                    ))}\n                                    <li>\n                                      <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)}>\n                                        Shop all {c.label.toLowerCase()}\n                                      </a>\n                                    </li>\n                                  </ul>\n                                </motion.div>\n                              ) : null}\n                            </AnimatePresence>\n                          </>\n                        ) : (\n                          <a href={c.href} className={cn(\"flex min-h-12 items-center text-[15px] font-medium text-sk-ink\", ring)}>\n                            {c.label}\n                          </a>\n                        )}\n                      </li>\n                    )\n                  })}\n                </ul>\n              </div>\n\n              <div className=\"grid gap-1 border-t border-sk-line p-2\">\n                <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)}>\n                  <User size={18} aria-hidden className=\"text-sk-muted\" />\n                  <span className=\"flex-1\">Account</span>\n                  <ChevronRight size={16} aria-hidden className=\"text-sk-muted\" />\n                </a>\n                <a\n                  href=\"#cart\"\n                  onClick={() => sk.emit(\"interact\", \"cart_open\")}\n                  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)}\n                >\n                  <ShoppingBag size={18} aria-hidden className=\"text-sk-muted\" />\n                  <span className=\"flex-1\">Cart</span>\n                  <span className=\"font-sk-mono text-[11px] tabular-nums text-sk-muted\">{cartCount} {cartCount === 1 ? \"item\" : \"items\"}</span>\n                </a>\n              </div>\n            </motion.div>\n          </div>\n        ) : null}\n      </AnimatePresence>\n    </>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/nav/nav-drawer.tsx"
    }
  ],
  "meta": {
    "section": "nav",
    "variant": "drawer",
    "niche": [
      "any"
    ],
    "commerce": [
      "search",
      "cart-count"
    ],
    "previewHeight": 660
  }
}