MynduiMyndui
0

Anatomy of the Mega Menu

How a trigger row shares one sliding highlight, measures its own panel content, and morphs its footprint from a one-column list to a two-column spread.

01/04Triggers, pill, and panel
Triggers
plain buttons/links — one per top-level item
Highlight pill
one layoutId span, rendered only under the hot trigger
Panel
opens below a trigger that carries sections
tsx
{isHot && (<motion.span  layoutId={highlightId}  transition={spring}  className="absolute inset-0 rounded-lg bg-accent"/>)}<span className="relative">{item.label}</span>
01

Triggers, pill, and panel

A mega menu is really two small components stapled together: a trigger row that behaves like a segmented control, and a panel that has to resize itself for whatever content the hot trigger happens to carry. Neither part is hard alone — the trick is making them agree on timing so hovering across triggers never feels like it's fighting the panel underneath it.

The trigger row is plain buttons and links — no wrapper markup per item. A single highlight motion.span renders only under whichever trigger is "hot" (hovered ?? active), and triggers that carry a sections array open a panel of link columns below them:

hasPanel triggers get aria-haspopup="true" and aria-expanded; plain links skip both — a mega menu isn't obligated to make every top-level item a dropdown.

02/04One sliding highlight

layoutId binds the pill to one node; framer springs it (stiffness 320, damping 32, mass 0.9) from box to box

tsx
const spring = reduceMotion? { duration: 0 }: ({ type: "spring", stiffness: 320, damping: 32, mass: 0.9 } as const);
02

One sliding highlight

Because the pill is one motion.span with a shared layoutId, moving the pointer from trigger to trigger doesn't cross-fade two separate backgrounds — framer diffs the old box against the new one and springs a single element between them, direction-aware for free:

The extra mass: 0.9 (versus the 520/32 pill used elsewhere in the system) is deliberate — a mega menu's trigger row is wider and the highlight travels further per hop, so a touch more inertia keeps the slide from feeling twitchy over that distance.

03/04Panel footprint morph

measures content → animates width/height · 0.3s [0.22,1,0.36,1]

tsx
useIsoLayoutEffect(() => {if (active === null) return;const el = contentRef.current;if (el) setBounds({ width: el.offsetWidth, height: el.offsetHeight });}, [active]);<motion.divanimate={{ width: bounds?.width ?? "auto", height: bounds?.height ?? "auto" }}transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}className="relative overflow-hidden"/>
03

Panel footprint morph

This is the one place in Myndui navigation that intentionally animates a layout property. Hover from Product (two columns) to Resources (one) and the open panel doesn't unmount — it keeps the shell, measures the incoming content, and morphs width/height so the footprint flows around it while the links cross-fade:

Faking that with scale would distort the text inside the panel. Reading offsetWidth/offsetHeight and animating layout is the honest trade: the only way to resize a box around genuinely different content without crushing it. The inner content still cross-fades with AnimatePresence mode="popLayout" keyed on active, so the outgoing column set never overlaps the incoming one mid-morph. In the Result below, hover Product then Resources to feel both the sliding highlight and the panel flow at once.

Hovering a trigger doesn't open its panel instantly — scheduleOpen waits openDelay (default 80ms) before committing, so a fast sweep across several triggers doesn't flash every panel along the way. Leaving waits longer, closeDelay (140ms), and hovering into the open panel itself cancels that timer:

tsx
 
const scheduleOpen = (index: number) => {
  clearTimeout(closeTimer.current);
  setHovered(index);
  openTimer.current = setTimeout(() => setActive(index), openDelay);
};
// panel:
onMouseEnter={() => clearTimeout(closeTimer.current)}

The asymmetry — open slower than intent-to-close is forgiven — is what makes the menu feel deliberate on the way in and forgiving on the way out. Keyboard users get the same open path through onFocus={() => scheduleOpen(index)}, and on touch, hover panels don't work at all, so md:hidden swaps the whole thing for an accordion drawer instead of trying to simulate hover.

04/04Result
04

The result

One shared highlight, one measured panel, two independently-tuned timers — that's the whole mega menu, whether it's showing a single link or a two-column spread.

Motion Score

Mega MenuDDLayout-triggering
Dheightpanel + mobile-accordion shared-layout morph
Dwidthpanel shared-layout morph
StranslatePosition / lift via translate
SscaleScale spring / press
SrotateRotation
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →