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-meganav-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-megaSource
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.
