Navigation

Mega Menu

Nav items opening a shared wide panel with staggered columns; moving between items retargets the panel instead of reopening it.

Install

npx shadcn@latest add @paragon/mega-menu

mega-menu.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

interface MegaMenuContextValue {
  close: () => void;
}

const MegaMenuContext = React.createContext<MegaMenuContextValue | null>(null);

export interface MegaMenuItemProps {
  label: string;
  children: React.ReactNode;
}

/**
 * Declares one top-level item and its panel content. Rendered by the parent
 * <MegaMenu> — it never renders itself.
 */
export function MegaMenuItem(_props: MegaMenuItemProps): React.ReactNode {
  return null;
}

export interface MegaMenuProps extends React.ComponentProps<"nav"> {}

/**
 * Nav items opening a shared wide panel: origin-top scale 0.98→1 + fade over
 * 200ms, content columns staggered 30ms apart. Moving between top-level items
 * retargets the open panel — content crossfades with a directional slide, no
 * close/reopen flicker. Escape and outside clicks close; ArrowDown enters the
 * panel.
 */
export function MegaMenu({ className, children, ...props }: MegaMenuProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const navRef = React.useRef<HTMLElement>(null);
  const triggerRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const openTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const reducedMotion = useReducedMotion();

  const [menu, setMenu] = React.useState<{ index: number | null; dir: number }>(
    { index: null, dir: 0 },
  );

  const setOpen = React.useCallback((index: number | null) => {
    setMenu((prev) => ({
      index,
      dir:
        index !== null && prev.index !== null && index !== prev.index
          ? Math.sign(index - prev.index)
          : 0,
    }));
  }, []);

  const close = React.useCallback(() => setOpen(null), [setOpen]);

  React.useEffect(() => {
    return () => {
      if (openTimer.current) clearTimeout(openTimer.current);
      if (closeTimer.current) clearTimeout(closeTimer.current);
    };
  }, []);

  // Close on any press outside the nav or its panel.
  React.useEffect(() => {
    if (menu.index === null) return;
    const onPointerDown = (event: PointerEvent) => {
      if (!navRef.current?.contains(event.target as Node)) close();
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [menu.index, close]);

  const hoverOpen = (index: number) => {
    if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
      return;
    }
    if (closeTimer.current) clearTimeout(closeTimer.current);
    if (menu.index !== null) {
      // Already open: retarget immediately, never close/reopen.
      if (menu.index !== index) setOpen(index);
      return;
    }
    if (openTimer.current) clearTimeout(openTimer.current);
    openTimer.current = setTimeout(() => setOpen(index), 80);
  };

  const focusPanel = () => {
    requestAnimationFrame(() => {
      document
        .getElementById(`${id}-panel`)
        ?.querySelector<HTMLElement>("a, button")
        ?.focus();
    });
  };

  const items: { label: string; content: React.ReactNode }[] = [];
  const row: React.ReactNode[] = [];

  React.Children.forEach(children, (child) => {
    if (React.isValidElement<MegaMenuItemProps>(child) && child.type === MegaMenuItem) {
      const index = items.length;
      items.push({ label: child.props.label, content: child.props.children });
      const isOpen = menu.index === index;
      row.push(
        <button
          key={`mega-menu-trigger-${index}`}
          ref={(node) => {
            triggerRefs.current[index] = node;
          }}
          type="button"
          data-open={isOpen || undefined}
          aria-expanded={isOpen}
          aria-haspopup="true"
          aria-controls={`${id}-panel`}
          onClick={() => setOpen(isOpen ? null : index)}
          onMouseEnter={() => hoverOpen(index)}
          onKeyDown={(event) => {
            if (event.key === "ArrowDown") {
              event.preventDefault();
              setOpen(index);
              focusPanel();
            }
          }}
          className="flex h-8 items-center gap-1 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors duration-150 ease-out select-none hover:bg-accent hover:text-foreground data-open:bg-accent data-open:text-foreground"
        >
          {child.props.label}
          <ChevronDown
            aria-hidden
            className={cn(
              "size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none",
              isOpen && "rotate-180",
            )}
          />
        </button>,
      );
    } else {
      row.push(child);
    }
  });

  const active = menu.index !== null ? items[menu.index] : null;

  return (
    <MegaMenuContext.Provider value={{ close }}>
      <nav
        ref={navRef}
        data-slot="mega-menu"
        onMouseEnter={() => {
          if (closeTimer.current) clearTimeout(closeTimer.current);
        }}
        onMouseLeave={() => {
          if (openTimer.current) clearTimeout(openTimer.current);
          if (menu.index === null) return;
          if (closeTimer.current) clearTimeout(closeTimer.current);
          closeTimer.current = setTimeout(close, 150);
        }}
        onKeyDown={(event) => {
          if (event.key !== "Escape" || menu.index === null) return;
          const trigger = triggerRefs.current[menu.index];
          close();
          trigger?.focus();
        }}
        className={cn("relative flex items-center gap-1", className)}
        {...props}
      >
        <style href={`paragon-mega-menu-${id}`} precedence="paragon">{`
          @keyframes mm-col-${id} {
            from {
              opacity: 0;
              transform: translateX(calc(var(--mm-dir, 0) * 16px)) translateY(4px);
              filter: blur(2px);
            }
          }
          [data-mm-content="${id}"] > * {
            animation: mm-col-${id} 250ms var(--ease-out) backwards;
          }
          [data-mm-content="${id}"] > :nth-child(2) { animation-delay: 30ms; }
          [data-mm-content="${id}"] > :nth-child(3) { animation-delay: 60ms; }
          [data-mm-content="${id}"] > :nth-child(4) { animation-delay: 90ms; }
          @media (prefers-reduced-motion: reduce) {
            [data-mm-content="${id}"] > * { animation: none !important; }
          }
        `}</style>
        {row}
        <div className="absolute inset-x-0 top-[calc(100%+0.5rem)] z-50">
          <AnimatePresence>
            {active && (
              <motion.div
                id={`${id}-panel`}
                initial={{ opacity: 0, scale: 0.98 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{
                  opacity: 0,
                  scale: 0.99,
                  transition: { duration: reducedMotion ? 0 : 0.1 },
                }}
                transition={{
                  duration: reducedMotion ? 0 : 0.2,
                  ease: [0.22, 1, 0.36, 1],
                }}
                style={{ transformOrigin: "top" }}
                className="rounded-xl bg-popover p-4 text-popover-foreground shadow-overlay"
              >
                <div
                  key={menu.index}
                  data-mm-content={id}
                  style={{ "--mm-dir": String(menu.dir) } as React.CSSProperties}
                  className="grid auto-cols-fr grid-flow-col gap-6"
                >
                  {active.content}
                </div>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </nav>
    </MegaMenuContext.Provider>
  );
}

/** A plain top-level link that sits alongside mega-menu triggers. */
export function MegaMenuLink({
  className,
  ...props
}: React.ComponentProps<"a">) {
  const context = React.useContext(MegaMenuContext);
  return (
    <a
      data-slot="mega-menu-link"
      onMouseEnter={() => context?.close()}
      className={cn(
        "flex h-8 items-center rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-foreground",
        className,
      )}
      {...props}
    />
  );
}

export interface MegaMenuColumnProps extends React.ComponentProps<"div"> {
  title?: string;
}

export function MegaMenuColumn({
  title,
  className,
  children,
  ...props
}: MegaMenuColumnProps) {
  return (
    <div className={cn("flex min-w-0 flex-col gap-0.5", className)} {...props}>
      {title && (
        <div className="px-2 pb-1.5 text-[11px] font-medium tracking-wider text-muted-foreground/80 uppercase">
          {title}
        </div>
      )}
      {children}
    </div>
  );
}

export interface MegaMenuColumnItemProps extends React.ComponentProps<"a"> {
  title: string;
  description?: string;
  icon?: React.ReactNode;
}

export function MegaMenuColumnItem({
  title,
  description,
  icon,
  className,
  ...props
}: MegaMenuColumnItemProps) {
  return (
    <a
      data-slot="mega-menu-column-item"
      className={cn(
        "group flex items-start gap-3 rounded-lg p-2 transition-colors duration-150 ease-out hover:bg-accent",
        className,
      )}
      {...props}
    >
      {icon && (
        <span
          aria-hidden
          className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors duration-150 ease-out group-hover:text-foreground [&_svg]:size-4"
        >
          {icon}
        </span>
      )}
      <span className="flex min-w-0 flex-col">
        <span className="text-sm font-medium text-foreground">{title}</span>
        {description && (
          <span className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
            {description}
          </span>
        )}
      </span>
    </a>
  );
}