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-drawer
nav-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-drawer

Source

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.

Mobile drawer nav, StoreKit