Mega Menu Navbar with Morphing Dropdown Panel

A site header with three dropdowns that share a single panel. Moving from Platform to Solutions to Resources does not close one menu and open another: the same white panel glides sideways, springs to the new width and height, and the old links slide out one way while the new ones slide in from the other. A small pointer on the top edge tracks the trigger, and the page behind dims and softens while the menu is open. The preview steps through the dropdowns until a visitor hovers or clicks. On phones the triggers sit on their own row and the panel fills the width, stacking its columns.

Code

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { ArrowRight, BookOpen, Boxes, Building2, ChevronDown, FileText, Gauge, LifeBuoy, MapPinned, PackageCheck, Radio, Route, ShieldCheck, Ship, Store, Truck, Warehouse } from "lucide-react";
import type { LucideIcon } from "lucide-react";

type Item = { icon: LucideIcon; title: string; text: string };
type Section = { id: string; label: string; width: number; columns: { heading: string; items: Item[] }[]; feature?: { tag: string; title: string; text: string } };

// One entry per dropdown. `width` is the panel width on desktop: the panel resizes to it.
const sections: Section[] = [
  {
    id: "platform",
    label: "Platform",
    width: 860,
    columns: [
      {
        heading: "Move",
        items: [
          { icon: Route, title: "Route planner", text: "Routes built around driver hours." },
          { icon: Truck, title: "Fleet board", text: "Every vehicle on one screen." },
          { icon: Ship, title: "Ocean and air", text: "Containers booked like a van." },
        ],
      },
      {
        heading: "Track",
        items: [
          { icon: MapPinned, title: "Live map", text: "Positions every thirty seconds." },
          { icon: PackageCheck, title: "Proof of delivery", text: "Photo and signature on every drop." },
          { icon: Gauge, title: "Carrier scorecards", text: "On-time rates to show a supplier." },
        ],
      },
    ],
    feature: { tag: "New this month", title: "Dock scheduling", text: "Carriers book their own unloading slot." },
  },
  {
    id: "solutions",
    label: "Solutions",
    width: 560,
    columns: [
      {
        heading: "By team",
        items: [
          { icon: Warehouse, title: "Warehouse leads", text: "Know what lands today." },
          { icon: Radio, title: "Dispatchers", text: "Reassign a load in two clicks." },
          { icon: ShieldCheck, title: "Compliance", text: "Customs papers, kept in order." },
        ],
      },
      {
        heading: "By business",
        items: [
          { icon: Store, title: "Retail chains", text: "Store replenishment on time." },
          { icon: Boxes, title: "Wholesalers", text: "Pallets in, pallets out." },
          { icon: Building2, title: "Manufacturers", text: "Parts before the line stops." },
        ],
      },
    ],
  },
  {
    id: "resources",
    label: "Resources",
    width: 340,
    columns: [
      {
        heading: "Learn",
        items: [
          { icon: BookOpen, title: "Guides", text: "Setup in an afternoon." },
          { icon: FileText, title: "API reference", text: "Webhooks for every event." },
          { icon: LifeBuoy, title: "Support desk", text: "People who answer by name." },
        ],
      },
    ],
  },
];

const DEMO_MS = 2200; // time on each dropdown while the preview plays on its own
const MORPH = { type: "spring", stiffness: 340, damping: 34, mass: 0.9 } as const; // how the panel slides and resizes
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
const CLOSE_DELAY = 160; // grace period when the pointer leaves, so a diagonal move does not close the menu

export default function MegaMenuNavbarWithMorphingDropdownPanel() {
  const reduce = useReducedMotion();
  const root = useRef<HTMLElement>(null);
  const bar = useRef<HTMLElement>(null);
  const content = useRef<HTMLDivElement>(null);
  const triggers = useRef<(HTMLButtonElement | null)[]>([]);
  const closeTimer = useRef<number>();
  const inView = useInView(root, { amount: 0.3 });

  const [active, setActive] = useState<number | null>(0);
  const [last, setLast] = useState(0);
  const [demo, setDemo] = useState(true);
  const [barWidth, setBarWidth] = useState(1100);
  const [box, setBox] = useState({ x: 0, width: sections[0].width, height: 330, notch: 80 });

  const compact = barWidth < 700;
  const open = active !== null;
  const shown = active ?? last;
  const dir = shown >= last ? 1 : -1;

  const go = (next: number | null) => {
    setLast(shown);
    setActive(next);
  };

  // The preview steps through the dropdowns until someone uses the menu.
  useEffect(() => {
    if (!demo || reduce || !inView) return;
    const id = window.setInterval(() => {
      setActive((a) => {
        setLast(a ?? 0);
        return ((a ?? -1) + 1) % sections.length;
      });
    }, DEMO_MS);
    return () => window.clearInterval(id);
  }, [demo, reduce, inView]);

  useEffect(() => {
    const el = bar.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setBarWidth(el.offsetWidth));
    ro.observe(el);
    setBarWidth(el.offsetWidth);
    return () => {
      ro.disconnect();
      window.clearTimeout(closeTimer.current);
    };
  }, []);

  // Measure where the panel should be: centred under its trigger, kept inside the bar, as tall as its content.
  useIsoLayoutEffect(() => {
    const trigger = triggers.current[shown];
    const el = content.current;
    const barEl = bar.current;
    if (!trigger || !el || !barEl) return;
    const width = Math.min(sections[shown].width, barWidth);
    const centre = trigger.getBoundingClientRect().left - barEl.getBoundingClientRect().left + trigger.offsetWidth / 2;
    const x = Math.max(0, Math.min(centre - width / 2, barWidth - width));
    setBox({ x, width, height: el.offsetHeight, notch: centre - x });
  }, [shown, barWidth, compact]);

  const takeOver = () => {
    setDemo(false);
    window.clearTimeout(closeTimer.current);
  };
  const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
    if (e.key !== "Escape" || active === null) return;
    takeOver();
    triggers.current[active]?.focus();
    go(null);
  };

  const section = sections[shown];
  return (
    <section ref={root} className="relative overflow-hidden bg-[#eef2fb] text-slate-900">
      {/* Page behind the menu, so the dropdown has something real to sit over. */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-70" style={{ backgroundImage: "linear-gradient(to right, rgba(47,91,255,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(47,91,255,0.07) 1px, transparent 1px)", backgroundSize: "56px 56px", maskImage: "radial-gradient(ellipse at 50% 30%, black 20%, transparent 75%)", WebkitMaskImage: "radial-gradient(ellipse at 50% 30%, black 20%, transparent 75%)" }} />

      <div className="relative mx-auto h-[700px] max-w-6xl px-4 pt-5 sm:px-6">
        <header ref={bar} className="relative z-20" onKeyDown={onKeyDown} onPointerEnter={(e) => e.pointerType === "mouse" && window.clearTimeout(closeTimer.current)} onPointerLeave={(e) => { if (e.pointerType !== "mouse" || demo) return; closeTimer.current = window.setTimeout(() => go(null), CLOSE_DELAY); }}>
          <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-2 rounded-2xl border border-white bg-white/80 px-3 py-2 shadow-sm shadow-blue-900/5 backdrop-blur sm:px-4">
            <a href="#" className="flex h-10 items-center gap-2 rounded-lg px-1 text-base font-bold tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff]">
              <span aria-hidden="true" className="grid h-7 w-7 place-items-center rounded-lg bg-[#2f5bff]">
                <span className="h-2.5 w-2.5 rounded-full border-2 border-white" />
              </span>
              Tidewater
            </a>

            <nav aria-label="Main" className="order-last w-full sm:order-none sm:w-auto">
              <ul className="flex items-center justify-between sm:justify-start sm:gap-1">
                {sections.map((s, i) => {
                  const on = open && active === i;
                  return (
                    <li key={s.id}>
                      <button
                        ref={(el) => {
                          triggers.current[i] = el;
                        }}
                        type="button"
                        aria-expanded={on}
                        aria-controls="mega-menu-panel"
                        onClick={() => {
                          takeOver();
                          go(on ? null : i);
                        }}
                        onPointerEnter={(e) => {
                          if (e.pointerType !== "mouse") return;
                          takeOver();
                          go(i);
                        }}
                        className={`relative flex h-10 items-center gap-1 rounded-full px-3.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff] ${on ? "text-[#1d3fd1]" : "text-slate-700 hover:text-slate-950"}`}
                      >
                        {on && <motion.span layoutId="mega-menu-trigger" aria-hidden="true" className="absolute inset-0 rounded-full bg-[#2f5bff]/10" transition={reduce ? { duration: 0 } : MORPH} />}
                        <span className="relative">{s.label}</span>
                        <ChevronDown aria-hidden="true" className={`relative h-3.5 w-3.5 transition-transform duration-300 motion-reduce:transition-none ${on ? "rotate-180" : ""}`} />
                      </button>
                    </li>
                  );
                })}
                <li className="hidden sm:block">
                  <a href="#" onPointerEnter={(e) => { if (e.pointerType === "mouse" && !demo) go(null); }} className="flex h-10 items-center rounded-full px-3.5 text-sm font-medium text-slate-700 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff]">
                    Pricing
                  </a>
                </li>
              </ul>
            </nav>

            <div className="flex items-center gap-1">
              <a href="#" className="hidden h-10 items-center rounded-full px-3.5 text-sm font-medium text-slate-700 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff] md:flex">
                Sign in
              </a>
              <a href="#" className="flex h-10 items-center rounded-full bg-slate-950 px-4 text-sm font-semibold text-white transition-colors hover:bg-[#2f5bff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff] focus-visible:ring-offset-2">
                Book a demo
              </a>
            </div>
          </div>

          {/* One panel for every dropdown. It moves and resizes; only the content inside is swapped. */}
          <AnimatePresence>
            {open && (
              <motion.div
                id="mega-menu-panel"
                key="panel"
                className="absolute left-0 top-full z-10 pt-3"
                style={{ transformOrigin: `${box.notch}px 0px` }}
                initial={reduce ? false : { opacity: 0, scale: 0.96, y: -8, x: box.x, width: box.width }}
                animate={{ opacity: 1, scale: 1, y: 0, x: box.x, width: box.width }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -6, transition: { duration: 0.16 } }}
                transition={reduce ? { duration: 0 } : MORPH}
              >
                <motion.span aria-hidden="true" className="absolute top-[7px] h-3.5 w-3.5 rounded-[3px] border-l border-t border-slate-200 bg-white" style={{ left: -7, rotate: 45 }} initial={false} animate={{ x: box.notch }} transition={reduce ? { duration: 0 } : MORPH} />
                <motion.div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl shadow-blue-950/15" initial={false} animate={{ height: box.height }} transition={reduce ? { duration: 0 } : MORPH}>
                  <AnimatePresence initial={false} custom={dir}>
                    <motion.div
                      key={section.id}
                      ref={content}
                      custom={dir}
                      className="absolute left-0 top-0"
                      style={{ width: box.width }}
                      variants={{
                        enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: 56 * d, filter: "blur(6px)" }),
                        centre: { opacity: 1, x: 0, filter: "blur(0px)" },
                        exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: -56 * d, filter: "blur(6px)" }),
                      }}
                      initial="enter"
                      animate="centre"
                      exit="exit"
                      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 32 }}
                    >
                      <div className={`flex gap-2 p-3 ${compact ? "flex-col" : ""}`}>
                        {section.columns.map((col, c) => (
                          <div key={col.heading} className="min-w-0 flex-1">
                            <p className="px-3 pb-1 pt-2 font-mono text-[11px] uppercase tracking-[0.18em] text-slate-500">{col.heading}</p>
                            <ul>
                              {col.items.map((item, k) => (
                                <motion.li key={item.title} initial={reduce ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={reduce ? { duration: 0 } : { delay: 0.05 + (c * 3 + k) * 0.03, type: "spring", stiffness: 320, damping: 28 }}>
                                  <a href="#" className="group flex min-h-[52px] items-center gap-3 rounded-2xl px-3 py-2 transition-colors hover:bg-[#eef2fb] focus-visible:bg-[#eef2fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff]">
                                    <span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-700 transition-all duration-300 group-hover:-rotate-6 group-hover:scale-110 group-hover:bg-[#2f5bff] group-hover:text-white motion-reduce:transition-none motion-reduce:group-hover:rotate-0 motion-reduce:group-hover:scale-100">
                                      <item.icon aria-hidden="true" className="h-[18px] w-[18px]" />
                                    </span>
                                    <span className="min-w-0">
                                      <span className="block text-sm font-semibold text-slate-900">{item.title}</span>
                                      <span className="block truncate text-[13px] text-slate-600">{item.text}</span>
                                    </span>
                                  </a>
                                </motion.li>
                              ))}
                            </ul>
                          </div>
                        ))}

                        {section.feature && !compact && (
                          <motion.a href="#" initial={reduce ? false : { opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} transition={reduce ? { duration: 0 } : { delay: 0.12, type: "spring", stiffness: 260, damping: 26 }} className="group relative flex w-[216px] shrink-0 flex-col justify-end overflow-hidden rounded-2xl bg-[#1733b8] p-5 text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff] focus-visible:ring-offset-2">
                            <span aria-hidden="true" className="absolute inset-0 bg-gradient-to-br from-[#4f7bff] via-[#2f5bff] to-[#10278f]" />
                            {/* A small drawn dock: bays as bars, one of them pulsing. */}
                            <span aria-hidden="true" className="absolute inset-x-5 top-4 flex h-8 items-end gap-1.5">
                              {[52, 78, 40, 96, 64, 84, 46].map((h, i) => (
                                <span key={i} className={`flex-1 rounded-t-md ${i === 3 ? "bg-white animate-[mega-menu-bay_2.4s_ease-in-out_infinite] motion-reduce:animate-none" : "bg-white/25"}`} style={{ height: `${h}%` }} />
                              ))}
                            </span>
                            <span className="relative font-mono text-[11px] uppercase tracking-[0.18em] text-blue-100">{section.feature.tag}</span>
                            <span className="relative mt-1.5 text-lg font-semibold leading-tight">{section.feature.title}</span>
                            <span className="relative mt-1.5 text-[13px] leading-snug text-blue-50">{section.feature.text}</span>
                            <span className="relative mt-3 inline-flex items-center gap-1 text-sm font-semibold">
                              See how it works
                              <ArrowRight aria-hidden="true" className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1 motion-reduce:transition-none" />
                            </span>
                          </motion.a>
                        )}
                      </div>
                      <div className="flex items-center justify-between gap-3 border-t border-slate-100 bg-slate-50 px-6 py-3 text-[13px]">
                        <span className="truncate text-slate-600">{section.label === "Resources" ? "Status: all systems normal" : "Moving 41,000 loads a week"}</span>
                        <a href="#" className="inline-flex h-10 shrink-0 items-center gap-1 rounded-md font-semibold text-[#1d3fd1] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff]">
                          View all
                          <ArrowRight aria-hidden="true" className="h-3.5 w-3.5" />
                        </a>
                      </div>
                    </motion.div>
                  </AnimatePresence>
                </motion.div>
              </motion.div>
            )}
          </AnimatePresence>
        </header>

        <div className={`relative mx-auto max-w-3xl pt-20 text-center transition-all duration-500 motion-reduce:transition-none sm:pt-24 ${open ? "opacity-40 blur-[2px]" : ""}`}>
          <p className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-white px-3 py-1 text-xs font-medium text-[#1d3fd1]">
            <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-[#2f5bff]" />
            Dock scheduling is live
          </p>
          <h1 className="mt-6 text-4xl font-semibold tracking-tight text-slate-950 sm:text-6xl">Freight that tells you where it is.</h1>
          <p className="mx-auto mt-5 max-w-xl text-base text-slate-600 sm:text-lg">Plan routes, track every load and hand customers a delivery time they can trust, from one board your whole yard can read.</p>
          <div className="mt-8 flex flex-wrap justify-center gap-3">
            <a href="#" className="inline-flex h-12 items-center rounded-full bg-[#2f5bff] px-6 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff] focus-visible:ring-offset-2">Start a free trial</a>
            <a href="#" className="inline-flex h-12 items-center rounded-full border border-slate-300 bg-white px-6 text-sm font-semibold text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2f5bff] focus-visible:ring-offset-2">Watch a 2 minute tour</a>
          </div>
          <dl className="mx-auto mt-12 grid max-w-xl grid-cols-3 gap-3 text-left">
            {[
              ["98.2%", "on-time drops"],
              ["30 sec", "position refresh"],
              ["212", "carriers connected"],
            ].map(([n, l]) => (
              <div key={l} className="rounded-2xl border border-white bg-white/70 p-4">
                <dt className="text-xs text-slate-600">{l}</dt>
                <dd className="mt-1 text-xl font-semibold tabular-nums text-slate-950 sm:text-2xl">{n}</dd>
              </div>
            ))}
          </dl>
        </div>
      </div>
      <style>{`@keyframes mega-menu-bay { 0%, 100% { opacity: 1; transform: scaleY(1); } 50% { opacity: 0.55; transform: scaleY(0.82); } } .animate-\\[mega-menu-bay_2\\.4s_ease-in-out_infinite\\] { transform-origin: bottom; }`}</style>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/mega-menu-navbar-with-morphing-dropdown-panel.json

When to use it

How to customize it

Accessibility

Questions

How does the panel morph between sections?

There is only one panel element. When the active section changes, the component measures the trigger's centre and the new content's height, then animates the panel's `x`, `width` and `height` to those values with one shared spring. Because position and size move together, it reads as one object changing shape.

How does the content know which way to slide?

The component remembers the previously open index. If the new index is higher, the old content exits to the left and the new content enters from the right, and the reverse otherwise. That direction is passed to the framer-motion variants through the `custom` prop.

Why does the menu not close when I move the mouse diagonally?

Leaving the header starts a short timer (`CLOSE_DELAY`, 160ms) instead of closing at once, and re-entering cancels it. The panel also has invisible padding above it that bridges the gap to the bar.

How does it behave on a phone?

Below 700px of header width the panel uses the full width for every section, columns stack and the feature card is left out, so only the height changes between sections. Triggers open on tap.

Can I make it stick to the top of the page?

Yes. Add `sticky top-4 z-50` to the `<header>` and move it out of the fixed-height demo wrapper. The panel is positioned against the header, so it follows along.

Related: Morphing Segmented Navbar with Detached Active Pill, Floating Navbar That Shrinks to a Pill on Scroll, Dark Navbar with Dropdown Menu and Search Field.

More navbars or all free UI components. Start for free to have a full site built around it.

More in Navbars

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap