Mega Menu
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;
  openOnHover: boolean;
}

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

/* Static keyframes + static href: hoisted and deduped into <head> once for
 * every MegaMenu on the page (SPEC rule 11). The slide direction comes from
 * the --mm-dir custom property set on the content wrapper. */
const megaMenuStyles = `
@keyframes pg-mm-col {
  from {
    opacity: 0;
    transform: translateX(calc(var(--mm-dir, 0) * 16px)) translateY(4px);
    filter: blur(2px);
  }
}
[data-mm-content] > * {
  animation: pg-mm-col 250ms var(--ease-out) backwards;
}
[data-mm-content] > :nth-child(2) { animation-delay: 30ms; }
[data-mm-content] > :nth-child(3) { animation-delay: 60ms; }
[data-mm-content] > :nth-child(4) { animation-delay: 90ms; }
@media (prefers-reduced-motion: reduce) {
  [data-mm-content] > * { animation: none !important; }
}
`;

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"> {
  /**
   * Open panels on pointer hover (fine pointers only). When false, panels
   * open on click/ArrowDown and stay until dismissed — hovering across
   * triggers still retargets an already-open panel.
   */
  openOnHover?: boolean;
}

/**
 * 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 — outgoing content blur-fades out (100ms) while
 * the incoming columns slide in from the travel direction, no close/reopen
 * flicker. Escape, outside clicks, and focus leaving the nav all close;
 * ArrowDown enters the panel and ArrowLeft/ArrowRight move between triggers.
 */
export function MegaMenu({
  openOnHover = true,
  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 (!openOnHover) 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[] = [];
  let triggerCount = 0;

  React.Children.forEach(children, (child) => {
    if (React.isValidElement<MegaMenuItemProps>(child) && child.type === MegaMenuItem) {
      triggerCount += 1;
    }
  });

  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();
              return;
            }
            // Menubar-style roving between top-level items; an open panel
            // retargets to follow the focused trigger.
            if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
              event.preventDefault();
              const step = event.key === "ArrowRight" ? 1 : -1;
              const next = (index + step + triggerCount) % triggerCount;
              triggerRefs.current[next]?.focus();
              if (menu.index !== null) setOpen(next);
            }
          }}
          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, openOnHover }}>
      <nav
        ref={navRef}
        data-slot="mega-menu"
        onMouseEnter={() => {
          if (closeTimer.current) clearTimeout(closeTimer.current);
        }}
        onMouseLeave={() => {
          if (openTimer.current) clearTimeout(openTimer.current);
          // Click-opened panels are sticky — only hover-open mode follows
          // the pointer away.
          if (!openOnHover || 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();
        }}
        onBlur={(event) => {
          // Tabbing (or clicking focus) out of the nav closes the panel.
          if (
            menu.index !== null &&
            !event.currentTarget.contains(event.relatedTarget as Node | null)
          ) {
            close();
          }
        }}
        className={cn("relative flex items-center gap-1", className)}
        {...props}
      >
        <style href="paragon-mega-menu" precedence="paragon">
          {megaMenuStyles}
        </style>
        {row}
        {/* Anchored at the left of the trigger row (not inset to it), so the
            panel sizes to its columns instead of being squeezed to the narrow
            trigger width. Capped to the viewport so it never overflows its
            frame. */}
        <div className="absolute top-[calc(100%+0.5rem)] left-0 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="w-max max-w-[min(44rem,calc(100vw-2rem))] rounded-xl bg-popover p-5 text-popover-foreground shadow-overlay"
              >
                {/* Retarget crossfade: outgoing columns blur-fade out over
                    100ms (popLayout lifts them out of flow) while incoming
                    ones run the staggered directional slide. */}
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.div
                    key={menu.index}
                    exit={{
                      opacity: 0,
                      ...(reducedMotion ? {} : { filter: "blur(2px)" }),
                    }}
                    transition={{ duration: reducedMotion ? 0 : 0.1 }}
                    data-mm-content=""
                    style={{ "--mm-dir": String(menu.dir) } as React.CSSProperties}
                    className="flex gap-8"
                  >
                    {active.content}
                  </motion.div>
                </AnimatePresence>
              </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={() => {
        // In hover mode a plain link dismisses the panel; click-opened
        // panels stay until an explicit dismiss.
        if (context?.openOnHover) 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 (
    // A comfortable min width keeps each column's title + descriptions from
    // cramping or wrapping awkwardly when several columns share the panel.
    <div className={cn("flex w-56 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>
  );
}