A commerce navigation header with category flyouts, an expanding search field, account, and an animated cart badge.
npx shadcn@latest add @paragon/storefront-header"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Search, ShoppingBag, User, Menu, X, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface StoreCategory {
label: string;
/** Optional flyout links; when present the item opens a panel on hover/click. */
links?: { label: string; href?: string }[];
}
export interface StorefrontHeaderProps extends React.ComponentProps<"header"> {
brand?: string;
categories?: StoreCategory[];
cartCount?: number;
}
const DEFAULT_CATEGORIES: StoreCategory[] = [
{
label: "Men",
links: [
{ label: "New arrivals" },
{ label: "Outerwear" },
{ label: "Shirts" },
{ label: "Denim" },
{ label: "Footwear" },
],
},
{
label: "Women",
links: [
{ label: "New arrivals" },
{ label: "Dresses" },
{ label: "Knitwear" },
{ label: "Trousers" },
{ label: "Accessories" },
],
},
{ label: "Home" },
{ label: "Sale" },
];
/**
* A commerce navigation header: brand, category nav with a hover/focus flyout
* (grid-rows-free — a positioned panel that fades and lifts), a search field
* that expands, account, and a cart button with an animated count badge. Fully
* keyboard-navigable; the mobile menu opens a disclosure. Reduced motion keeps
* fades, drops movement.
*/
export function StorefrontHeader({
brand = "AERA",
categories = DEFAULT_CATEGORIES,
cartCount = 3,
className,
...props
}: StorefrontHeaderProps) {
const reduced = useReducedMotion();
const [openCat, setOpenCat] = React.useState<string | null>(null);
const [mobileOpen, setMobileOpen] = React.useState(false);
const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
}, []);
const openWith = (label: string) => {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpenCat(label);
};
const scheduleClose = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => setOpenCat(null), 120);
};
return (
<header
className={cn(
"relative w-full rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpenCat(null);
}}
{...props}
>
<div className="flex h-14 items-center gap-3 px-4">
<button
type="button"
aria-label="Open menu"
aria-expanded={mobileOpen}
onClick={() => setMobileOpen((o) => !o)}
className="pressable flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground sm:hidden"
>
{mobileOpen ? (
<X className="size-5" aria-hidden />
) : (
<Menu className="size-5" aria-hidden />
)}
</button>
<a
href="#"
onClick={(e) => e.preventDefault()}
className="text-base font-semibold tracking-[0.2em] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{brand}
</a>
<nav
aria-label="Categories"
className="ml-2 hidden items-center sm:flex"
>
{categories.map((cat) => {
const hasFlyout = !!cat.links?.length;
const isOpen = openCat === cat.label;
return (
<div
key={cat.label}
className="relative"
onMouseEnter={() => hasFlyout && openWith(cat.label)}
onMouseLeave={scheduleClose}
>
<button
type="button"
aria-expanded={hasFlyout ? isOpen : undefined}
onFocus={() => hasFlyout && openWith(cat.label)}
onClick={() =>
hasFlyout && setOpenCat(isOpen ? null : cat.label)
}
className={cn(
"flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
cat.label === "Sale" && "text-destructive",
)}
>
{cat.label}
{hasFlyout && (
<ChevronDown
aria-hidden
className={cn(
"size-3.5 text-muted-foreground transition-transform duration-200 ease-out motion-reduce:transition-none",
isOpen && "rotate-180",
)}
/>
)}
</button>
<AnimatePresence>
{hasFlyout && isOpen && (
<motion.div
initial={
reduced
? { opacity: 0 }
: { opacity: 0, y: -8, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, y: -6, filter: "blur(2px)" }
}
transition={{
type: "spring",
duration: 0.25,
bounce: 0,
}}
onMouseEnter={() => openWith(cat.label)}
onMouseLeave={scheduleClose}
className="absolute left-0 top-full z-20 mt-1 w-48 origin-top rounded-xl bg-popover p-1.5 shadow-overlay"
>
<ul>
{cat.links!.map((link) => (
<li key={link.label}>
<a
href={link.href ?? "#"}
onClick={(e) =>
!link.href && e.preventDefault()
}
className="block rounded-lg px-2.5 py-1.5 text-sm text-popover-foreground outline-none transition-colors duration-150 hover:bg-accent focus-visible:bg-accent"
>
{link.label}
</a>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</nav>
<div className="ml-auto flex items-center gap-1">
<SearchField />
<button
type="button"
aria-label="Account"
className="pressable flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
>
<User className="size-5" aria-hidden />
</button>
<button
type="button"
aria-label={`Cart, ${cartCount} items`}
className="pressable relative flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
>
<ShoppingBag className="size-5" aria-hidden />
<AnimatePresence>
{cartCount > 0 && (
<motion.span
key={cartCount}
initial={reduced ? { opacity: 0 } : { scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: "spring", duration: 0.3, bounce: 0.2 }}
className="absolute -right-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground tabular-nums"
>
{cartCount}
</motion.span>
)}
</AnimatePresence>
</button>
</div>
</div>
{/* Mobile menu */}
<div
className="grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-in-out) sm:hidden motion-reduce:transition-none"
style={{ gridTemplateRows: mobileOpen ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<nav
aria-label="Categories"
className="flex flex-col gap-0.5 border-t border-border p-2"
>
{categories.map((cat) => (
<a
key={cat.label}
href="#"
onClick={(e) => e.preventDefault()}
className={cn(
"rounded-lg px-3 py-2 text-sm font-medium outline-none transition-colors duration-150 hover:bg-accent focus-visible:bg-accent",
cat.label === "Sale" && "text-destructive",
)}
>
{cat.label}
</a>
))}
</nav>
</div>
</div>
</header>
);
}
function SearchField() {
const [expanded, setExpanded] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
return (
<div
className={cn(
"flex items-center rounded-lg transition-[background-color,box-shadow] duration-200 ease-out",
expanded && "bg-secondary shadow-border",
)}
>
<button
type="button"
aria-label={expanded ? "Search" : "Open search"}
onClick={() => {
setExpanded(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<Search className="size-5" aria-hidden />
</button>
{/* Reveal by clipping a fixed-width field (transform/opacity/clip-path
only — never animate width). */}
<div
className="grid transition-[grid-template-columns] duration-200 ease-out motion-reduce:transition-none"
style={{ gridTemplateColumns: expanded ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<input
ref={inputRef}
type="search"
placeholder="Search products…"
onBlur={(e) => {
if (!e.target.value) setExpanded(false);
}}
tabIndex={expanded ? 0 : -1}
className={cn(
"w-40 min-w-0 bg-transparent pr-2 text-sm outline-none transition-opacity duration-150 placeholder:text-muted-foreground sm:w-52",
expanded ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
</div>
);
}