Nav items opening a shared wide panel with staggered columns; moving between items retargets the panel instead of reopening it.
npx shadcn@latest add @paragon/mega-menu"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>
);
}