Mega menu header

Shop panel with categories, a featured product, and a promo tile. Hover, click, or keyboard.

blockmegaanymerchandisingpromo
$npx shadcn@latest add @storekit/nav-mega
nav-mega

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-mega
$pnpm dlx shadcn@latest add @storekit/nav-mega
$yarn dlx shadcn@latest add @storekit/nav-mega
$bunx --bun shadcn@latest add @storekit/nav-mega

Source

components/storekit/nav/nav-mega.tsx
227 lines
"use client"

import { useCallback, useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ArrowRight, ChevronDown, Search, ShoppingBag } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Price } from "@/components/storekit/ui/price"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Header with a "Shop" mega panel. Opens on hover, click, or ArrowDown from
   the trigger; closes on mouse leave (with a short grace delay), Escape, or
   when focus leaves the menu. Escape returns focus to the trigger. Pass
   `defaultOpen` to render with the panel showing (previews, screenshots). */

type Category = { label: string; href: string; note?: string }
type Featured = { title: string; price: number; compareAt?: number; image: string; href: string }
type Promo = { eyebrow: string; title: string; cta: 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"

export function NavMega({
  brandName = "Morrow",
  markSrc,
  categories = [
    { label: "Powders", href: "#powders", note: "Greens, protein, electrolytes" },
    { label: "Capsules and gummies", href: "#capsules-gummies", note: "Focus, Magnesium Night" },
    { label: "Drinks", href: "#drinks", note: "Sparkling Recovery" },
    { label: "Bundles", href: "#bundles", note: "Morning and night sets" },
    { label: "Subscribe and save", href: "#subscribe", note: "15% off every order" },
  ],
  featured = { title: "Daily Greens", price: 4900, image: "/brands/morrow/greens.webp", href: "#daily-greens" },
  promo = { eyebrow: "Bundle", title: "Any three pouches, 15% off", cta: "Build a routine", href: "#bundles" },
  links = [
    { label: "Lab results", href: "#lab-results" },
    { label: "Journal", href: "#journal" },
  ],
  cartCount = 0,
  onCart,
  defaultOpen = false,
  className,
}: {
  brandName?: string
  markSrc?: string
  categories?: Category[]
  featured?: Featured
  promo?: Promo
  links?: { label: string; href: string }[]
  cartCount?: number
  onCart?: () => void
  defaultOpen?: boolean
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("nav", "nav-mega")
  const [open, setOpen] = useState(defaultOpen)
  const panelId = useId()
  const wrapRef = useRef<HTMLDivElement>(null)
  const triggerRef = useRef<HTMLButtonElement>(null)
  const panelRef = useRef<HTMLDivElement>(null)
  const closeTimer = useRef<number | undefined>(undefined)
  const focusFirst = useRef(false)
  const [origin, setOrigin] = useState("top left")

  const show = useCallback(() => {
    window.clearTimeout(closeTimer.current)
    const t = triggerRef.current
    const header = t?.closest("header")
    if (t && header) {
      const a = t.getBoundingClientRect()
      const b = header.getBoundingClientRect()
      setOrigin(`${Math.round(a.left + a.width / 2 - b.left)}px top`)
    }
    setOpen(true)
  }, [])
  const hide = useCallback(() => {
    window.clearTimeout(closeTimer.current)
    closeTimer.current = window.setTimeout(() => setOpen(false), 120)
  }, [])

  useEffect(() => () => window.clearTimeout(closeTimer.current), [])

  useEffect(() => {
    if (!open) return
    if (focusFirst.current) {
      focusFirst.current = false
      requestAnimationFrame(() => panelRef.current?.querySelector<HTMLElement>("a")?.focus())
    }
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setOpen(false)
        triggerRef.current?.focus()
      }
    }
    document.addEventListener("keydown", onKey)
    return () => document.removeEventListener("keydown", onKey)
  }, [open])

  const linkCls = cn("inline-flex h-11 items-center gap-1 rounded-sk-btn px-2.5 text-sm sm:px-3 text-sk-muted transition-colors duration-200 hover:text-sk-ink", ring)

  return (
    <header ref={sk.ref} {...sk.attrs} className={cn("relative z-30 border-b border-sk-line bg-sk-paper", className)}>
      <div className="mx-auto flex h-16 max-w-6xl items-center gap-1 px-4 sm:gap-6 sm:px-8">
        <a href="/" className={cn("flex min-h-11 min-w-0 items-center gap-2 sm:gap-2.5 rounded-sk-btn pr-1", ring)}>
          {markSrc ? <img src={markSrc} alt="" className="h-7 w-7 shrink-0 object-contain" /> : null}
          <span className="truncate whitespace-nowrap font-sk-display text-[15px] font-semibold tracking-[-0.02em] text-sk-ink">{brandName}</span>
        </a>

        <nav aria-label="Primary" className="flex shrink-0 items-center sm:flex-1">
          <ul className="flex items-center gap-1">
            <li>
              <div
                ref={wrapRef}
                onMouseEnter={show}
                onMouseLeave={hide}
                onBlur={(e) => {
                  if (!wrapRef.current?.contains(e.relatedTarget as Node)) setOpen(false)
                }}
              >
                <button
                  ref={triggerRef}
                  type="button"
                  aria-expanded={open}
                  aria-controls={panelId}
                  onClick={() => (open ? setOpen(false) : show())}
                  onKeyDown={(e) => {
                    if (e.key === "ArrowDown") {
                      e.preventDefault()
                      focusFirst.current = true
                      if (open) panelRef.current?.querySelector<HTMLElement>("a")?.focus()
                      else show()
                    }
                  }}
                  className={cn(linkCls, open && "text-sk-ink")}
                >
                  Shop
                  <ChevronDown size={14} aria-hidden className={cn("transition-transform duration-200", open && "rotate-180")} />
                </button>
                <AnimatePresence>
                  {open ? (
                    <motion.div
                      ref={panelRef}
                      id={panelId}
                      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
                      animate={{ opacity: 1, y: 0, scale: 1 }}
                      exit={reduce ? { opacity: 0, transition: { duration: 0.15 } } : { opacity: 0, y: 6, scale: 0.95, transition: { duration: 0.15 } }}
                      transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
                      style={{ transformOrigin: origin }}
                      className="absolute inset-x-0 top-full max-h-[calc(100dvh-4rem)] overflow-y-auto overscroll-contain border-b border-sk-line bg-sk-paper shadow-[0_24px_60px_-24px_rgb(0_0_0/0.35)]"
                    >
                      <div className="mx-auto grid max-w-6xl gap-6 px-4 py-6 sm:px-8 md:grid-cols-[1.1fr_1fr_1fr]">
                        <div>
                          <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Categories</p>
                          <ul className="mt-3 grid">
                            {categories.map((c) => (
                              <li key={c.href}>
                                <a
                                  href={c.href}
                                  className={cn("group flex min-h-11 items-center justify-between gap-3 rounded-sk-btn px-2 -mx-2 transition-colors duration-200 hover:bg-sk-ink/[0.04]", ring)}
                                >
                                  <span className="text-sm font-medium text-sk-ink">{c.label}</span>
                                  {c.note ? <span className="hidden text-right text-[13px] text-sk-muted sm:inline">{c.note}</span> : null}
                                </a>
                              </li>
                            ))}
                          </ul>
                        </div>
                        <a
                          href={featured.href}
                          className={cn("group flex gap-4 rounded-sk-card border border-sk-line bg-sk-card p-4 transition-colors duration-200 hover:border-sk-ink/20 md:flex-col", ring)}
                        >
                          <ProductImage src={featured.image} alt={featured.title} className="w-28 shrink-0 rounded-sk-card md:w-full md:aspect-[16/10]" />
                          <span className="flex flex-col gap-1">
                            <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Featured</span>
                            <span className="text-sm font-medium text-sk-ink">{featured.title}</span>
                            <Price amount={featured.price} compareAt={featured.compareAt} size="sm" />
                          </span>
                        </a>
                        <a
                          href={promo.href}
                          className={cn("group hidden flex-col justify-between gap-6 rounded-sk-card bg-sk-ink p-5 md:flex text-sk-paper transition-opacity duration-200 hover:opacity-95", ring)}
                        >
                          <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-paper/60">{promo.eyebrow}</span>
                          <span className="font-sk-display text-xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance">{promo.title}</span>
                          <span className="inline-flex items-center gap-1.5 text-sm font-medium">
                            {promo.cta}
                            <ArrowRight size={14} aria-hidden className="transition-transform duration-200 group-hover:translate-x-0.5" />
                          </span>
                        </a>
                      </div>
                    </motion.div>
                  ) : null}
                </AnimatePresence>
              </div>
            </li>
            {links.map((l) => (
              <li key={l.href} className="hidden sm:block">
                <a href={l.href} className={linkCls}>{l.label}</a>
              </li>
            ))}
          </ul>
        </nav>

        <div className="ml-auto flex shrink-0 items-center">
          <button type="button" aria-label="Search" className={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]", ring)}>
            <Search size={18} aria-hidden />
          </button>
          <button
            type="button"
            aria-label={`Cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}
            onClick={() => {
              sk.emit("interact", "cart_open")
              onCart?.()
            }}
            className={cn("relative grid h-11 w-11 place-items-center rounded-sk-btn text-sk-ink transition-colors duration-200 hover:bg-sk-ink/[0.05]", ring)}>
            <ShoppingBag size={18} 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>
      </div>
    </header>
  )
}

Usage

Import from "@/components/storekit/nav/nav-mega" and pass your product data. The demo above exercises the full prop surface with sample values.

Mega menu header, StoreKit