E-commerce

Storefront Header

A commerce navigation header with category flyouts, an expanding search field, account, and an animated cart badge.

Install

npx shadcn@latest add @paragon/storefront-header

storefront-header.tsx

"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>
  );
}