Mega Nav

Separate dropdowns per section make each one feel like a different place, so browsing across a product's navigation hides the fact that the sections are siblings.

NavigationProductivityDashboardOnboardingadvanced
  • Keyboard
  • Touch
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/mega-nav.json

The problem

Giving each navigation section its own dropdown makes every section feel like a different place. The panel vanishes and a new one appears, so moving from "Platform" to "Developers" reads as two separate visits rather than as scanning across one menu — and the sections stop looking like siblings.

Keeping one panel and moving it holds the relationship visible. The arrow stays pointed at whichever item you are on, so the panel is always visibly about that item, and the content slides sideways rather than being replaced.

Use cases

  • A marketing or product header with three to five sections
  • A docs site nav where each section needs descriptions, not just labels
  • Any nav where sections are peers and people compare across them

Not for: small screens — the panel is a two-column grid anchored to a horizontal bar, and neither survives a phone. Ship a disclosure list there instead. Not for a single dropdown: one section does not need shared-layout machinery. Not for application chrome where the nav must not open on hover.

Installation

bash
npm install motion lucide-react

Then copy MegaNav.tsx. It depends only on @/lib/cn.

Usage

tsx
import MegaNav from "@/patterns/mega-nav/MegaNav";

<MegaNav
  label="Product"
  items={[
    {
      label: "Platform",
      links: [
        {
          label: "Dashboard",
          description: "Manage your stores",
          href: "/dash",
        },
        { label: "Analytics", description: "Track reach", href: "/analytics" },
      ],
    },
    {
      label: "Developers",
      links: [{ label: "API Docs", description: "Integrate", href: "/docs" }],
      banner: { description: "Start building with the API.", href: "/start" },
    },
  ]}
/>;

API

PropTypeDefaultDescription
itemsreadonly NavItem[]—Top-level sections. Empty renders nothing.
labelstring"Main"Names the navigation landmark.
classNamestring—Merged onto the <nav>.

NavItem: { label: string; links: readonly NavLink[]; banner?: NavBanner }. NavLink: { label: string; description: string; href: string }. NavBanner: { description: string; href: string }.

Keyboard

KeyAction
TabMove onto the bar, then into the open panel's links
← / →Move between top-level sections
↓Open the focused section
Enter / SpaceToggle the focused section
EscapeClose and return focus to the section button

Accessibility

It could not be opened with a keyboard at all. The top-level items were <li> elements with onMouseEnter and nothing else — not focusable, not activatable, with no aria-expanded and no path to the panel. Everything below the bar was unreachable for anyone not using a pointer, which is the whole navigation.

They are buttons now, with aria-expanded and aria-controls. Hover still opens, because that is the interaction being recreated, but it is no longer the only way in: arrows walk the bar, ↓ and Enter open, Escape closes and returns focus to the button that opened it. The panel's links are ordinary anchors, so Tab reaches them once it is open.

Escape is bound on the document rather than on the bar, because by the time you want it your focus is usually on a link inside the panel.

The banner was hardcoded indigo — bg-indigo-50 with text-indigo-900 — which in dark mode put near-black text on a pale wash. It uses the accent token at low opacity now, so it reads as a highlight in both themes.

group-hover:translate-x-1 on the banner's arrow never fired: the modifier was on the icon with no group on any ancestor. The class is on the anchor now.

Known limitation: this is a desktop pattern and the metadata says so — responsive: false. A two-column panel hanging off a horizontal bar has no sensible phone form, and pretending otherwise with a breakpoint would produce something that is neither. Use a disclosure list on small screens.

Performance

transform and opacity, plus the arrow's left.

The pill is a layoutId inside a LayoutGroup, so moving between sections is one shared-layout projection rather than two fades. The layoutId is scoped with useId, so two navs on a page do not fight over one pill — the original's global "active-pill" string meant the second instance would steal it.

The arrow's offset is measured in useLayoutEffect, not useEffect. Measuring after paint means the first open renders the arrow at the previous item's offset and then jumps to the right one.

A layoutId="dropdown-background" element is gone. It was an absolutely positioned bg-surface layer inside a bg-surface panel — invisible, and animating on every section change.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

MegaNav.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
} from "motion/react";
import { ArrowRight } from "lucide-react";

import { cn } from "@/lib/cn";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface NavLink {
  label: string;
  description: string;
  href: string;
}

export interface NavBanner {
  description: string;
  href: string;
}

export interface NavItem {
  label: string;
  links: readonly NavLink[];
  /** Optional full-width call to action below the links. */
  banner?: NavBanner;
}

export interface MegaNavProps extends React.ComponentPropsWithoutRef<"nav"> {
  items: readonly NavItem[];
  /** Names the navigation landmark. @default "Main" */
  label?: string;
}

/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original                                    */
/* -------------------------------------------------------------------------- */

/** The pill that slides between top-level items. */
const pillSpring = { type: "spring" as const, stiffness: 300, damping: 30 };
/** The panel's entrance. Lighter mass, so it arrives ahead of the pill. */
const panelSpring = {
  type: "spring" as const,
  stiffness: 300,
  damping: 30,
  mass: 0.5,
};
/** The arrow tracking the active item — stiffer, because it must not lag the pill. */
const arrowSpring = { type: "spring" as const, stiffness: 400, damping: 40 };
const SWAP = { duration: 0.2 } as const;

/* -------------------------------------------------------------------------- */
/* Panel                                                                       */
/* -------------------------------------------------------------------------- */

function NavPanel({ item }: { item: NavItem }) {
  return (
    <div className="grid grid-cols-2 gap-4">
      {item.links.map((link) => (
        <a
          key={link.label}
          href={link.href}
          className="focus-ring group rounded-lg border border-border p-4 transition-colors hover:bg-surface-subtle"
        >
          <p className="font-semibold text-content">{link.label}</p>
          <p className="text-sm text-content-muted">{link.description}</p>
        </a>
      ))}

      {item.banner && (
        <a
          href={item.banner.href}
          // `group` lives here, on the element the arrow's `group-hover` is
          // relative to. The original put the modifier on the icon with no
          // group anywhere above it, so the nudge never fired.
          className="focus-ring group col-span-2 rounded-lg bg-accent/10 p-4 transition-colors hover:bg-accent/15"
        >
          <span className="flex items-center justify-between gap-4">
            <span className="font-semibold text-content">
              {item.banner.description}
            </span>
            <ArrowRight
              className="size-5 shrink-0 text-content transition-transform group-hover:translate-x-1"
              strokeWidth={2}
              aria-hidden="true"
            />
          </span>
        </a>
      )}
    </div>
  );
}

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A navigation bar whose sections share one panel.
 *
 * Separate dropdowns make every section feel like a different place, and the
 * panel appearing and disappearing between them hides the fact that they are
 * siblings. Keeping one panel and moving it — with an arrow that stays pointed
 * at whichever item you are on — keeps the relationship between the sections
 * visible while you browse across them.
 */
export function MegaNav({
  items,
  label = "Main",
  className,
  ...rest
}: MegaNavProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const panelId = `${uid}-panel`;

  const navRef = useRef<HTMLElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [active, setActive] = useState<number | null>(null);
  const [arrowLeft, setArrowLeft] = useState(0);

  /*
   * Measured in a layout effect, not a passive one. The arrow is positioned
   * from the active item's centre, and measuring after paint means the first
   * open renders it at the previous offset and then jumps.
   */
  useLayoutEffect(() => {
    if (active === null) return;
    const item = itemRefs.current[active];
    const nav = navRef.current;
    if (!item || !nav) return;
    const itemRect = item.getBoundingClientRect();
    const navRect = nav.getBoundingClientRect();
    setArrowLeft(itemRect.left + itemRect.width / 2 - navRect.left);
  }, [active]);

  const close = useCallback((restoreFocus = false) => {
    setActive((current) => {
      if (restoreFocus && current !== null) itemRefs.current[current]?.focus();
      return null;
    });
  }, []);

  /* Escape closes from anywhere inside, including from a link in the panel. */
  useEffect(() => {
    if (active === null) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") close(true);
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [active, close]);

  /*
   * Hover opens, as in the original, but hover cannot be the only way in.
   * Arrows walk the bar, Enter and Space open, and the panel is reachable by
   * Tab once it is open.
   */
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
      switch (event.key) {
        case "ArrowRight":
          event.preventDefault();
          itemRefs.current[(index + 1) % items.length]?.focus();
          return;
        case "ArrowLeft":
          event.preventDefault();
          itemRefs.current[(index - 1 + items.length) % items.length]?.focus();
          return;
        case "ArrowDown":
          event.preventDefault();
          setActive(index);
          return;
        case "Escape":
          event.preventDefault();
          close(true);
          return;
        default:
          return;
      }
    },
    [close, items.length],
  );

  if (items.length === 0) return null;

  return (
    <nav
      ref={navRef}
      aria-label={label}
      className={cn("relative", className)}
      onPointerLeave={() => setActive(null)}
      {...rest}
    >
      <LayoutGroup>
        <ul className="flex items-center gap-2 rounded-full bg-surface p-1.5 shadow-resting">
          {items.map((item, index) => (
            <li key={item.label} className="relative">
              <button
                ref={(node) => {
                  itemRefs.current[index] = node;
                }}
                type="button"
                aria-expanded={active === index}
                aria-controls={active === index ? panelId : undefined}
                onPointerEnter={() => setActive(index)}
                onClick={() => setActive(active === index ? null : index)}
                onKeyDown={(event) => handleKeyDown(event, index)}
                className={cn(
                  "focus-ring relative cursor-pointer rounded-full px-4 py-2.5",
                  "font-medium whitespace-nowrap transition-colors",
                  active === index ? "text-content" : "text-content-muted",
                )}
              >
                <span className="relative z-10">{item.label}</span>

                {active === index && (
                  <motion.span
                    layoutId={`${uid}-pill`}
                    aria-hidden="true"
                    className="absolute inset-0 z-0 rounded-full bg-surface-subtle"
                    transition={reduce ? { duration: 0.01 } : pillSpring}
                  />
                )}
              </button>
            </li>
          ))}
        </ul>
      </LayoutGroup>

      <AnimatePresence>
        {active !== null && (
          <motion.div
            key="panel"
            id={panelId}
            className="absolute top-full left-0 z-20 mt-3 w-auto"
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 15 }}
            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: 15 }}
            transition={reduce ? { duration: 0.01 } : panelSpring}
          >
            <div className="relative rounded-xl border border-border bg-surface shadow-overlay">
              <motion.div
                aria-hidden="true"
                className="absolute -top-2"
                initial={false}
                animate={{ left: arrowLeft }}
                transition={reduce ? { duration: 0.01 } : arrowSpring}
              >
                <div className="size-4 -translate-x-1/2 rotate-45 border-t border-l border-border bg-surface" />
              </motion.div>

              <div className="relative p-8">
                <AnimatePresence mode="wait">
                  <motion.div
                    key={active}
                    initial={reduce ? { opacity: 0 } : { opacity: 0, x: -20 }}
                    animate={reduce ? { opacity: 1 } : { opacity: 1, x: 0 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, x: 20 }}
                    transition={reduce ? { duration: 0.01 } : SWAP}
                  >
                    <NavPanel item={items[active]} />
                  </motion.div>
                </AnimatePresence>
              </div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </nav>
  );
}

export default MegaNav;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react), LayoutGroup + layoutId
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Adapted from Stripe's product navigation. Renamed from Stripe Navigation, since the library names patterns for the job rather than for whoever shipped them first. Rebuilt with a props API, a keyboard path, focus restoration, themed colours and reduced-motion handling. The 300/30 pill spring, the lighter-mass panel entrance, the 400/40 arrow and the 200ms content slide are unchanged.