Skip to content

Mega Menu Nav

Mega menu navbar for a luxury retail site: the panel glides between menus, with link columns, image cards and a sliding underline.

  • Navbars
  • Fashion & retail
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Drawer sub panels use the inert attribute (Chrome 102+, Safari 15.5+, Firefox 112+).

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/mega-menu-nav.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/navbars/mega-menu-nav.tsx
1/**
2 * MegaMenuNav, a luxury retail header with full-width mega panels. Hover or
3 * click a top item and a panel opens with columns of links plus two editorial
4 * image cards. Moving between items re-measures the panel so its height
5 * glides from one menu to the next while the content slides in from the side
6 * you came from, and an underline slides between the top items.
7 *
8 * Props: brand, city, notice, menus (label, columns, cards, cardRatio),
9 * utility links, bagCount.
10 * CSS vars: --fd-mmn-paper, --fd-mmn-ink, --fd-mmn-accent.
11 *
12 * Behaviour: the header is `position: sticky` with a negative top equal to
13 * the notice strip, so the strip scrolls away and the main row stays.
14 * Browser support: all evergreen browsers.
15 * Accessibility: top items are buttons with aria-expanded/aria-controls;
16 * ArrowDown moves into the open panel, Escape closes and returns focus,
17 * focus leaving the header closes it. The mobile drawer is a modal dialog
18 * with a focus trap and drill-down sub panels with a Back button.
19 * Reduced motion: panel height and content swap without sliding.
20 */
21import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
22import type { KeyboardEvent as ReactKeyboardEvent } from "react";
23
24export interface MmnLink { label: string; href: string; note?: string }
25export interface MmnColumn { heading: string; links: MmnLink[] }
26export interface MmnCard { src: string; alt: string; kicker: string; title: string; href: string }
27export interface MmnMenu { label: string; columns: MmnColumn[]; cards: [MmnCard, MmnCard]; cardRatio?: string }
28export interface MegaMenuNavProps {
29 brand?: string;
30 city?: string;
31 notice?: string;
32 menus?: MmnMenu[];
33 utility?: MmnLink[];
34 bagCount?: number;
35 className?: string;
36}
37
38const MENUS: MmnMenu[] = [
39 {
40 label: "Fragrance",
41 cardRatio: "5 / 6",
42 columns: [
43 { heading: "Eaux de parfum", links: [
44 { label: "Ambre Noir Nº 7", href: "#ambre", note: "New" },
45 { label: "Thé Vert de Kyoto", href: "#the" },
46 { label: "Pêche de Vigne", href: "#peche" },
47 { label: "Nord", href: "#nord" },
48 { label: "Crépuscule", href: "#crepuscule" },
49 { label: "All fragrances", href: "#all" },
50 ] },
51 { heading: "By family", links: [
52 { label: "Woody & amber", href: "#woody" },
53 { label: "Green & tea", href: "#green" },
54 { label: "Fruit & musk", href: "#fruit" },
55 { label: "Cold florals", href: "#floral" },
56 ] },
57 { heading: "Services", links: [
58 { label: "Discovery set, 8 × 2 ml", href: "#discovery" },
59 { label: "Hand engraving", href: "#engrave" },
60 { label: "Refill a flacon", href: "#refill" },
61 ] },
62 ],
63 cards: [
64 { src: "/img/lux/perfume-noir-800.webp", alt: "Amber glass flacon of Ambre Noir on a dark plinth", kicker: "New extrait", title: "Ambre Noir, made with labdanum from one farm in Crete", href: "#ambre" },
65 { src: "/img/lux/perfume-green-800.webp", alt: "Green flacon in a pale green arch", kicker: "Bestseller", title: "Thé Vert de Kyoto, the shop's first scent since 1931", href: "#the" },
66 ],
67 },
68 {
69 label: "Home",
70 cardRatio: "1 / 1",
71 columns: [
72 { heading: "Scent the room", links: [
73 { label: "Candles, 220 g", href: "#candles" },
74 { label: "Reed diffusers", href: "#reeds" },
75 { label: "Room sprays", href: "#sprays" },
76 ] },
77 { heading: "Objects", links: [
78 { label: "Stoneware vases", href: "#vases" },
79 { label: "Tea service", href: "#tea" },
80 { label: "Incense & holders", href: "#incense" },
81 ] },
82 ],
83 cards: [
84 { src: "/img/lux/still-vase-800.webp", alt: "White stoneware vase with an olive branch and lemons", kicker: "Objects", title: "The Ménerbes vase, thrown in Provence", href: "#vases" },
85 { src: "/img/lux/still-tea-800.webp", alt: "Black teapot with two cups on a walnut shelf", kicker: "Ritual", title: "A tea service for one slow afternoon", href: "#tea" },
86 ],
87 },
88 {
89 label: "Textiles",
90 cardRatio: "3 / 4",
91 columns: [
92 { heading: "Linen", links: [
93 { label: "Throws", href: "#throws" },
94 { label: "Bed linen", href: "#bed" },
95 { label: "Table linen", href: "#table" },
96 { label: "Curtains, made to measure", href: "#curtains" },
97 ] },
98 { heading: "Colours", links: [
99 { label: "Oat", href: "#oat" },
100 { label: "Terracotta", href: "#terra" },
101 { label: "Encre", href: "#ink" },
102 ] },
103 { heading: "Care", links: [
104 { label: "Washing linen", href: "#wash" },
105 { label: "Linen water", href: "#water" },
106 ] },
107 ],
108 cards: [
109 { src: "/img/lux/drape-oat-800.webp", alt: "Folds of oat coloured linen", kicker: "Washed linen", title: "Oat, our undyed Normandy flax", href: "#oat" },
110 { src: "/img/lux/drape-terra-800.webp", alt: "Folds of terracotta linen", kicker: "Garment dyed", title: "Terracotta, dyed with madder root", href: "#terra" },
111 ],
112 },
113 {
114 label: "Gifts",
115 cardRatio: "3 / 2",
116 columns: [
117 { heading: "By price", links: [
118 { label: "Under €80", href: "#u80" },
119 { label: "€80 to €200", href: "#u200" },
120 { label: "Over €200", href: "#o200" },
121 ] },
122 { heading: "Wrapped by hand", links: [
123 { label: "Gift cards", href: "#cards" },
124 { label: "Corporate gifting", href: "#corporate" },
125 ] },
126 ],
127 cards: [
128 { src: "/img/lux/perfume-peach-800.webp", alt: "Peach flacon in a warm arch", kicker: "Under €120", title: "Pêche de Vigne, 30 ml", href: "#peche" },
129 { src: "/img/lux/still-grey-800.webp", alt: "White vase and bowls in grey light", kicker: "For the home", title: "The quiet shelf set", href: "#shelf" },
130 ],
131 },
132 {
133 label: "Maison",
134 cardRatio: "16 / 10",
135 columns: [
136 { heading: "About", links: [
137 { label: "Our atelier, rue de Saintonge", href: "#atelier" },
138 { label: "The 1931 ledger", href: "#ledger" },
139 { label: "Journal", href: "#journal" },
140 ] },
141 { heading: "Visit", links: [
142 { label: "Paris, Le Marais", href: "#paris" },
143 { label: "Copenhagen, Bredgade", href: "#cph" },
144 { label: "Book a scent consultation", href: "#book" },
145 ] },
146 ],
147 cards: [
148 { src: "/img/lux/drape-ink-800.webp", alt: "Deep blue silk folds", kicker: "Journal", title: "Why our boxes are lined in encre silk", href: "#silk" },
149 { src: "/img/lux/perfume-north-800.webp", alt: "Pale blue flacon in a white arch", kicker: "Copenhagen", title: "Nord, composed for the new store", href: "#nord" },
150 ],
151 },
152];
153
154const UTILITY: MmnLink[] = [
155 { label: "Stores", href: "#stores" },
156 { label: "France · EUR €", href: "#locale" },
157];
158
159export function MegaMenuNav({
160 brand = "Vérane",
161 city = "Paris",
162 notice = "Complimentary hand engraving on every 100 ml flacon until 24 December",
163 menus = MENUS,
164 utility = UTILITY,
165 bagCount = 2,
166 className,
167}: MegaMenuNavProps) {
168 const uid = useId();
169 const root = useRef<HTMLElement>(null);
170 const rail = useRef<HTMLDivElement>(null);
171 const bar = useRef<HTMLSpanElement>(null);
172 const shell = useRef<HTMLDivElement>(null);
173 const inner = useRef<HTMLDivElement>(null);
174 const triggers = useRef<(HTMLButtonElement | null)[]>([]);
175 const burger = useRef<HTMLButtonElement>(null);
176 const drawer = useRef<HTMLDivElement>(null);
177 const [active, setActive] = useState<number | null>(null);
178 const [dir, setDir] = useState(0);
179 const [hover, setHover] = useState<number | null>(null);
180 const [mobile, setMobile] = useState(false);
181 const [sub, setSub] = useState<number | null>(null);
182 const timer = useRef(0);
183 const prev = useRef<number | null>(null);
184
185 const go = (i: number | null) => {
186 window.clearTimeout(timer.current);
187 if (i !== null && prev.current !== null) setDir(i > prev.current ? 1 : -1);
188 else setDir(0);
189 prev.current = i;
190 setActive(i);
191 };
192 const later = (i: number | null, ms: number) => {
193 window.clearTimeout(timer.current);
194 timer.current = window.setTimeout(() => go(i), ms);
195 };
196
197 // Underline indicator follows the hovered or open item.
198 const ind = hover ?? active;
199 useLayoutEffect(() => {
200 const b = bar.current;
201 const t = ind !== null ? triggers.current[ind] : null;
202 if (!b) return;
203 if (!t) {
204 b.style.opacity = "0";
205 return;
206 }
207 const label = t.querySelector("span") as HTMLElement;
208 b.style.opacity = "1";
209 b.style.width = `${label.offsetWidth}px`;
210 b.style.transform = `translateX(${t.offsetLeft + label.offsetLeft}px)`;
211 }, [ind]);
212
213 // Panel height follows the content of the open menu.
214 useLayoutEffect(() => {
215 const s = shell.current, c = inner.current;
216 if (!s) return;
217 if (!c || active === null) {
218 s.style.height = "0px";
219 return;
220 }
221 const set = () => (s.style.height = `${c.offsetHeight}px`);
222 set();
223 const ro = new ResizeObserver(set);
224 ro.observe(c);
225 return () => ro.disconnect();
226 }, [active]);
227
228 // Escape + outside click for the desktop panel.
229 useEffect(() => {
230 if (active === null) return;
231 const onKey = (e: KeyboardEvent) => {
232 if (e.key === "Escape") {
233 const t = triggers.current[active];
234 go(null);
235 t?.focus();
236 }
237 };
238 const onDown = (e: PointerEvent) => {
239 if (root.current && !root.current.contains(e.target as Node)) go(null);
240 };
241 document.addEventListener("keydown", onKey);
242 document.addEventListener("pointerdown", onDown);
243 return () => {
244 document.removeEventListener("keydown", onKey);
245 document.removeEventListener("pointerdown", onDown);
246 };
247 }, [active]); // eslint-disable-line react-hooks/exhaustive-deps
248
249 // Mobile drawer: focus in, trap, Escape.
250 useEffect(() => {
251 if (!mobile) return;
252 const d = drawer.current;
253 window.setTimeout(() => d?.querySelector<HTMLElement>("[data-first]")?.focus(), 30);
254 const onKey = (e: KeyboardEvent) => {
255 if (e.key === "Escape") {
256 setMobile(false);
257 burger.current?.focus();
258 }
259 if (e.key === "Tab" && d) {
260 const f = Array.from(d.querySelectorAll<HTMLElement>("a,button")).filter((el) => el.offsetParent !== null && !el.closest("[inert]"));
261 const i = f.indexOf(document.activeElement as HTMLElement);
262 if (e.shiftKey && i <= 0) {
263 e.preventDefault();
264 f[f.length - 1]?.focus();
265 } else if (!e.shiftKey && i === f.length - 1) {
266 e.preventDefault();
267 f[0]?.focus();
268 }
269 }
270 };
271 const mq = matchMedia("(min-width: 900px)");
272 const onMq = () => mq.matches && setMobile(false);
273 document.addEventListener("keydown", onKey);
274 mq.addEventListener("change", onMq);
275 return () => {
276 document.removeEventListener("keydown", onKey);
277 mq.removeEventListener("change", onMq);
278 };
279 }, [mobile]);
280
281 useEffect(() => {
282 if (!mobile) setSub(null);
283 }, [mobile]);
284 useEffect(() => {
285 if (!mobile) return;
286 const d = drawer.current;
287 window.setTimeout(() => {
288 const target = sub === null ? d?.querySelector<HTMLElement>("[data-first]") : d?.querySelector<HTMLElement>("[data-back]");
289 target?.focus();
290 }, 60);
291 }, [sub, mobile]);
292
293 const onTriggerKey = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
294 if (e.key === "ArrowDown") {
295 e.preventDefault();
296 if (active !== i) go(i);
297 window.setTimeout(() => inner.current?.querySelector<HTMLElement>("a")?.focus(), 30);
298 } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
299 e.preventDefault();
300 const n = (i + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length;
301 triggers.current[n]?.focus();
302 if (active !== null) go(n);
303 }
304 };
305
306 const menu = active !== null ? menus[active] : null;
307 const panelId = `${uid}-panel`;
308
309 return (
310 <header
311 ref={root}
312 className={`fd-mmn${className ? " " + className : ""}`}
313 data-open={active !== null ? "" : undefined}
314 onPointerLeave={(e) => e.pointerType === "mouse" && later(null, 180)}
315 onPointerEnter={() => window.clearTimeout(timer.current)}
316 onBlur={(e) => {
317 if (!root.current?.contains(e.relatedTarget as Node) && !mobile) go(null);
318 }}
319 >
320 <style>{css}</style>
321 <div className="fd-mmn-notice">
322 <p>{notice}</p>
323 <div className="fd-mmn-util">
324 {utility.map((u) => <a key={u.href} href={u.href}>{u.label}</a>)}
325 </div>
326 </div>
327
328 <div className="fd-mmn-main">
329 <button ref={burger} type="button" className="fd-mmn-burger" aria-label="Open menu" aria-expanded={mobile} aria-controls={`${uid}-drawer`} onClick={() => setMobile(true)}>
330 <svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><path d="M3 7h16M3 15h10" stroke="currentColor" strokeWidth="1.3" fill="none" /></svg>
331 </button>
332 <nav className="fd-mmn-nav" aria-label="Shop">
333 <div ref={rail} className="fd-mmn-rail" onPointerLeave={() => setHover(null)}>
334 {menus.map((m, i) => (
335 <button
336 key={m.label}
337 ref={(el) => { triggers.current[i] = el; }}
338 type="button"
339 className="fd-mmn-top"
340 aria-expanded={active === i}
341 aria-controls={panelId}
342 data-on={active === i ? "" : undefined}
343 onPointerEnter={(e) => {
344 if (e.pointerType !== "mouse") return;
345 setHover(i);
346 if (active === null) later(i, 90);
347 else go(i);
348 }}
349 onClick={() => (active === i ? go(null) : go(i))}
350 onKeyDown={(e) => onTriggerKey(e, i)}
351 >
352 <span>{m.label}</span>
353 </button>
354 ))}
355 <span ref={bar} className="fd-mmn-bar" aria-hidden="true" />
356 </div>
357 </nav>
358
359 <a className="fd-mmn-brand" href="#top" aria-label={`${brand} ${city}, home`}>
360 <span className="fd-mmn-word">{brand}</span>
361 <span className="fd-mmn-city">{city}</span>
362 </a>
363
364 <div className="fd-mmn-tools">
365 <a href="#search" className="fd-mmn-tool fd-mmn-hide-sm">
366 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.2" /><path d="M12 12l4 4" stroke="currentColor" strokeWidth="1.2" /></svg>
367 <span>Search</span>
368 </a>
369 <a href="#account" className="fd-mmn-tool fd-mmn-hide-sm">Account</a>
370 <a href="#bag" className="fd-mmn-tool fd-mmn-bag" aria-label={`Bag, ${bagCount} items`}>
371 <span className="fd-mmn-bagtxt">Bag</span>
372 <span className="fd-mmn-count">{bagCount}</span>
373 </a>
374 </div>
375 </div>
376
377 <div ref={shell} id={panelId} className="fd-mmn-shell" role="region" aria-label={menu ? `${menu.label} menu` : undefined}>
378 {menu && (
379 <div ref={inner} key={active} className="fd-mmn-panel" data-dir={dir} onKeyDown={(e) => {
380 if (e.key === "ArrowUp" && active !== null && (e.target as HTMLElement) === inner.current?.querySelector("a")) {
381 e.preventDefault();
382 triggers.current[active]?.focus();
383 }
384 }}>
385 <div className="fd-mmn-cols">
386 {menu.columns.map((c) => (
387 <div key={c.heading} className="fd-mmn-col">
388 <p className="fd-mmn-h">{c.heading}</p>
389 <ul>
390 {c.links.map((l) => (
391 <li key={l.href}>
392 <a href={l.href} className="fd-mmn-link">{l.label}{l.note && <em>{l.note}</em>}</a>
393 </li>
394 ))}
395 </ul>
396 </div>
397 ))}
398 </div>
399 <div className="fd-mmn-cards">
400 {menu.cards.map((c) => (
401 <a key={c.href + c.src} href={c.href} className="fd-mmn-card">
402 <span className="fd-mmn-ph" style={{ aspectRatio: menu.cardRatio ?? "4 / 5" }}>
403 <img src={c.src} alt={c.alt} width={800} height={1000} loading="lazy" />
404 </span>
405 <span className="fd-mmn-kick">{c.kicker}</span>
406 <span className="fd-mmn-ct">{c.title}</span>
407 </a>
408 ))}
409 </div>
410 </div>
411 )}
412 </div>
413 <div className="fd-mmn-scrim" aria-hidden="true" onPointerEnter={() => later(null, 60)} onClick={() => go(null)} />
414
415 {/* Mobile drawer */}
416 <div className="fd-mmn-dwrap" data-show={mobile ? "" : undefined} hidden={!mobile}>
417 <div className="fd-mmn-dscrim" onClick={() => setMobile(false)} aria-hidden="true" />
418 <div ref={drawer} id={`${uid}-drawer`} className="fd-mmn-drawer" role="dialog" aria-modal="true" aria-label="Menu" data-sub={sub !== null ? "" : undefined}>
419 <div className="fd-mmn-dhead">
420 <span className="fd-mmn-word fd-mmn-dword">{brand}</span>
421 <button type="button" className="fd-mmn-x" aria-label="Close menu" onClick={() => { setMobile(false); burger.current?.focus(); }}>
422 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 4l12 12M16 4L4 16" stroke="currentColor" strokeWidth="1.3" /></svg>
423 </button>
424 </div>
425 <div className="fd-mmn-track">
426 <div className="fd-mmn-pane" inert={sub !== null ? true : undefined}>
427 <ul className="fd-mmn-dlist">
428 {menus.map((m, i) => (
429 <li key={m.label}>
430 <button type="button" className="fd-mmn-dtop" data-first={i === 0 ? "" : undefined} aria-expanded={sub === i} onClick={() => setSub(i)}>
431 <span>{m.label}</span>
432 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" strokeWidth="1.2" /></svg>
433 </button>
434 </li>
435 ))}
436 </ul>
437 <div className="fd-mmn-dutil">
438 <a href="#account">Account</a>
439 <a href="#search">Search</a>
440 {utility.map((u) => <a key={u.href} href={u.href}>{u.label}</a>)}
441 </div>
442 <p className="fd-mmn-dnote">{notice}</p>
443 </div>
444 <div className="fd-mmn-pane" inert={sub === null ? true : undefined}>
445 {sub !== null && (
446 <>
447 <button type="button" className="fd-mmn-back" data-back="" onClick={() => setSub(null)}>
448 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3L5 8l5 5" fill="none" stroke="currentColor" strokeWidth="1.2" /></svg>
449 All
450 </button>
451 <p className="fd-mmn-dsubt">{menus[sub].label}</p>
452 <a href={menus[sub].cards[0].href} className="fd-mmn-dcard">
453 <img src={menus[sub].cards[0].src} alt={menus[sub].cards[0].alt} width={800} height={1000} loading="lazy" />
454 <span>{menus[sub].cards[0].kicker}</span>
455 </a>
456 {menus[sub].columns.map((c) => (
457 <div key={c.heading} className="fd-mmn-dgroup">
458 <p className="fd-mmn-h">{c.heading}</p>
459 <ul>
460 {c.links.map((l) => <li key={l.href}><a href={l.href} onClick={() => setMobile(false)}>{l.label}{l.note && <em>{l.note}</em>}</a></li>)}
461 </ul>
462 </div>
463 ))}
464 </>
465 )}
466 </div>
467 </div>
468 </div>
469 </div>
470 </header>
471 );
472}
473
474const serif = `"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`;
475const sans = `ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif`;
476
477const css = `
478.fd-mmn {
479 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
480 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
481 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
482 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
483 --fd-mmn-paper: #f4f0e8;
484 --fd-mmn-ink: #1b1815;
485 --fd-mmn-soft: #6f675d;
486 --fd-mmn-line: #ddd5c8;
487 --fd-mmn-accent: #6e1f1c;
488 position: sticky; top: -34px; z-index: 50; display: block; margin: 0; padding: 0;
489 font: 400 15px/1.4 ${sans};
490 color: var(--fd-mmn-ink); background: var(--fd-mmn-paper);
491}
492.fd-mmn, .fd-mmn *, .fd-mmn *::before, .fd-mmn *::after { box-sizing: border-box; }
493.fd-mmn a:focus-visible, .fd-mmn button:focus-visible { outline: 2px solid color-mix(in srgb, var(--fd-mmn-accent) 70%, transparent); outline-offset: 2px; }
494
495.fd-mmn-notice { display: flex; align-items: center; justify-content: center; height: 34px; margin: 0; padding: 0 32px; background: var(--fd-mmn-ink); color: #efe8db; position: relative; }
496.fd-mmn-notice p { margin: 0; padding: 0; font: 400 0.75rem/1.2 ${sans}; letter-spacing: 0.02em; color: #efe8db; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
497.fd-mmn-util { position: absolute; right: 32px; top: 0; height: 100%; display: flex; align-items: center; gap: 20px; }
498.fd-mmn-util a { font: 400 0.75rem/1.2 ${sans}; color: #cfc6b7; text-decoration: none; }
499.fd-mmn-util a:hover { color: #fff; }
500
501.fd-mmn-main { position: relative; z-index: 3; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 76px; margin: 0; padding: 0 32px; background: var(--fd-mmn-paper); border-bottom: 1px solid var(--fd-mmn-line); }
502.fd-mmn-nav { margin: 0; padding: 0; }
503.fd-mmn-rail { position: relative; display: flex; align-items: center; gap: 2px; margin: 0 0 0 -12px; padding: 0; }
504.fd-mmn-top { position: relative; display: block; margin: 0; padding: 10px 12px; border: 0; background: none; font: 400 1rem/1.2 ${serif}; letter-spacing: 0; color: var(--fd-mmn-ink); cursor: pointer; appearance: none; -webkit-appearance: none; border-radius: 2px; }
505.fd-mmn-top span { display: inline-block; }
506.fd-mmn-bar { position: absolute; left: 0; bottom: 2px; height: 1px; width: 0; background: var(--fd-mmn-ink); opacity: 0; pointer-events: none; transition: transform var(--fd-dur-3) var(--fd-ease-out), width var(--fd-dur-3) var(--fd-ease-out), opacity var(--fd-dur-2) linear; }
507.fd-mmn-top[data-on] { color: var(--fd-mmn-accent); }
508
509.fd-mmn-brand { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 0; padding: 4px 8px; text-decoration: none; color: var(--fd-mmn-ink); }
510.fd-mmn-word { display: block; font: 400 1.875rem/1 ${serif}; letter-spacing: 0.28em; text-transform: uppercase; color: var(--fd-mmn-ink); padding-left: 0.28em; }
511.fd-mmn-city { display: block; font: 500 0.625rem/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.32em; text-transform: uppercase; color: var(--fd-mmn-soft); padding-left: 0.32em; }
512
513.fd-mmn-tools { display: flex; align-items: center; justify-content: flex-end; gap: 22px; margin: 0; padding: 0; }
514.fd-mmn-tool { display: inline-flex; align-items: center; gap: 8px; margin: 0; padding: 6px 0; font: 400 0.875rem/1.2 ${sans}; color: var(--fd-mmn-ink); text-decoration: none; background-image: linear-gradient(currentColor, currentColor); background-size: 0% 1px; background-repeat: no-repeat; background-position: 0 100%; transition: background-size var(--fd-dur-2) var(--fd-ease-out); }
515.fd-mmn-tool svg { display: block; }
516.fd-mmn-tool:hover { background-size: 100% 1px; }
517.fd-mmn-count { display: inline-grid; place-items: center; min-width: 20px; height: 20px; margin: 0; padding: 0 5px; border-radius: 9999px; font: 500 0.6875rem/1 ${sans}; color: #f4f0e8; background: var(--fd-mmn-accent); }
518
519.fd-mmn-shell { position: absolute; left: 0; right: 0; top: 100%; z-index: 2; height: 0; overflow: hidden; margin: 0; padding: 0; background: var(--fd-mmn-paper); transition: height var(--fd-dur-3) var(--fd-ease-in-out); box-shadow: 0 30px 40px -30px rgba(27,24,21,0.18); }
520.fd-mmn-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: 56px; margin: 0; padding: 36px 32px 44px; animation: fd-mmn-in var(--fd-dur-3) var(--fd-ease-out); }
521.fd-mmn-panel[data-dir="1"] { animation-name: fd-mmn-inr; }
522.fd-mmn-panel[data-dir="-1"] { animation-name: fd-mmn-inl; }
523@keyframes fd-mmn-in { from { opacity: 0; transform: translateY(-6px); } }
524@keyframes fd-mmn-inr { from { opacity: 0; transform: translateX(24px); } }
525@keyframes fd-mmn-inl { from { opacity: 0; transform: translateX(-24px); } }
526.fd-mmn-cols { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; margin: 0; padding: 0; }
527.fd-mmn-h { margin: 0 0 14px; padding: 0 0 10px; border-bottom: 1px solid var(--fd-mmn-line); font: 500 0.6875rem/1.2 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase; color: var(--fd-mmn-soft); }
528.fd-mmn-col ul, .fd-mmn-dgroup ul { margin: 0; padding: 0; list-style: none; }
529.fd-mmn-col li { margin: 0; padding: 0; }
530.fd-mmn-link { display: inline-flex; align-items: baseline; gap: 8px; margin: 0; padding: 5px 0; font: 400 1.0625rem/1.35 ${serif}; color: var(--fd-mmn-ink); text-decoration: none; transition: transform var(--fd-dur-2) var(--fd-ease-out), color var(--fd-dur-1) linear; }
531.fd-mmn-link:hover, .fd-mmn-link:focus-visible { transform: translateX(4px); color: var(--fd-mmn-accent); }
532.fd-mmn-link em, .fd-mmn-dgroup em { font: italic 400 0.8125rem/1 ${serif}; color: var(--fd-mmn-accent); }
533.fd-mmn-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 0; padding: 0; align-items: start; }
534.fd-mmn-card { display: block; margin: 0; padding: 0; color: var(--fd-mmn-ink); text-decoration: none; }
535.fd-mmn-ph { display: block; overflow: hidden; margin: 0 0 12px; background: #e6dfd2; }
536.fd-mmn-ph img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform var(--fd-dur-4) var(--fd-ease-out); }
537.fd-mmn-card:hover .fd-mmn-ph img { transform: scale(1.04); }
538.fd-mmn-kick { display: block; margin: 0 0 4px; font: 500 0.6875rem/1.2 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase; color: var(--fd-mmn-accent); }
539.fd-mmn-ct { display: block; font: italic 400 1rem/1.35 ${serif}; color: var(--fd-mmn-ink); max-width: 30ch; }
540
541.fd-mmn-scrim { position: fixed; inset: 0; z-index: 1; background: rgba(27,24,21,0.28); opacity: 0; pointer-events: none; transition: opacity var(--fd-dur-3) var(--fd-ease-out); }
542.fd-mmn[data-open] .fd-mmn-scrim { opacity: 1; pointer-events: auto; }
543
544.fd-mmn-burger { display: none; width: 40px; height: 40px; margin: 0 0 0 -10px; padding: 9px; border: 0; background: none; color: var(--fd-mmn-ink); cursor: pointer; appearance: none; -webkit-appearance: none; }
545.fd-mmn-burger svg { display: block; }
546
547.fd-mmn-dwrap { position: fixed; inset: 0; z-index: 60; }
548.fd-mmn-dwrap[hidden] { display: none; }
549.fd-mmn-dscrim { position: absolute; inset: 0; background: rgba(27,24,21,0.4); animation: fd-mmn-fade var(--fd-dur-3) var(--fd-ease-out); }
550@keyframes fd-mmn-fade { from { opacity: 0; } }
551.fd-mmn-drawer { position: absolute; left: 0; top: 0; bottom: 0; width: min(420px, 100%); display: flex; flex-direction: column; margin: 0; padding: 0; background: var(--fd-mmn-paper); overflow: hidden; animation: fd-mmn-slide var(--fd-dur-3) var(--fd-ease-out); }
552@keyframes fd-mmn-slide { from { transform: translateX(-100%); } }
553.fd-mmn-dhead { flex: none; display: flex; align-items: center; justify-content: space-between; height: 64px; margin: 0; padding: 0 12px 0 20px; border-bottom: 1px solid var(--fd-mmn-line); }
554.fd-mmn-dword { font-size: 1.25rem; }
555.fd-mmn-x { display: grid; place-items: center; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; background: none; color: var(--fd-mmn-ink); cursor: pointer; appearance: none; -webkit-appearance: none; }
556.fd-mmn-x svg { display: block; }
557.fd-mmn-track { position: relative; flex: 1; min-height: 0; display: grid; grid-template-columns: 100% 100%; margin: 0; padding: 0; transition: transform var(--fd-dur-3) var(--fd-ease-in-out); }
558.fd-mmn-drawer[data-sub] .fd-mmn-track { transform: translateX(-100%); }
559.fd-mmn-pane { min-height: 0; overflow-y: auto; margin: 0; padding: 12px 20px 32px; }
560.fd-mmn-dlist { margin: 0; padding: 0; list-style: none; }
561.fd-mmn-dlist li { margin: 0; padding: 0; border-bottom: 1px solid var(--fd-mmn-line); }
562.fd-mmn-dtop { display: flex; width: 100%; align-items: center; justify-content: space-between; margin: 0; padding: 18px 0; border: 0; background: none; font: 400 1.75rem/1.1 ${serif}; letter-spacing: -0.01em; color: var(--fd-mmn-ink); text-align: left; cursor: pointer; appearance: none; -webkit-appearance: none; }
563.fd-mmn-dtop svg { display: block; color: var(--fd-mmn-soft); }
564.fd-mmn-dutil { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin: 28px 0 0; padding: 0; }
565.fd-mmn-dutil a { font: 400 0.9375rem/1.3 ${sans}; color: var(--fd-mmn-ink); text-decoration: none; }
566.fd-mmn-dnote { margin: 28px 0 0; padding: 14px 0 0; border-top: 1px solid var(--fd-mmn-line); font: italic 400 0.9375rem/1.4 ${serif}; color: var(--fd-mmn-soft); }
567.fd-mmn-back { display: inline-flex; align-items: center; gap: 6px; margin: 4px 0 14px -4px; padding: 6px 4px; border: 0; background: none; font: 500 0.75rem/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase; color: var(--fd-mmn-soft); cursor: pointer; appearance: none; -webkit-appearance: none; }
568.fd-mmn-back svg { display: block; }
569.fd-mmn-dsubt { margin: 0 0 16px; font: italic 400 2.25rem/1 ${serif}; letter-spacing: -0.02em; color: var(--fd-mmn-ink); }
570.fd-mmn-dcard { position: relative; display: block; margin: 0 0 24px; height: 180px; overflow: hidden; text-decoration: none; }
571.fd-mmn-dcard img { display: block; width: 100%; height: 100%; object-fit: cover; }
572.fd-mmn-dcard span { position: absolute; left: 12px; bottom: 12px; padding: 5px 8px; font: 500 0.6875rem/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fd-mmn-ink); background: var(--fd-mmn-paper); }
573.fd-mmn-dgroup { margin: 0 0 24px; padding: 0; }
574.fd-mmn-dgroup li { margin: 0; padding: 0; }
575.fd-mmn-dgroup a { display: flex; align-items: baseline; gap: 8px; margin: 0; padding: 9px 0; font: 400 1.125rem/1.3 ${serif}; color: var(--fd-mmn-ink); text-decoration: none; }
576
577@media (max-width: 1180px) { .fd-mmn-util { display: none; } .fd-mmn-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
578@media (max-width: 899px) {
579 .fd-mmn { top: -30px; }
580 .fd-mmn-notice { height: 30px; padding: 0 16px; }
581 .fd-mmn-notice p { font-size: 0.6875rem; }
582 .fd-mmn-main { height: 64px; padding: 0 16px; grid-template-columns: 44px 1fr 44px; }
583 .fd-mmn-nav, .fd-mmn-shell, .fd-mmn-scrim, .fd-mmn-hide-sm, .fd-mmn-bagtxt { display: none; }
584 .fd-mmn-burger { display: block; }
585 .fd-mmn-word { font-size: 1.375rem; }
586 .fd-mmn-city { display: none; }
587 .fd-mmn-tools { gap: 0; }
588}
589@media (prefers-reduced-motion: reduce) {
590 .fd-mmn-shell, .fd-mmn-bar, .fd-mmn-track, .fd-mmn-link, .fd-mmn-ph img { transition: none; }
591 .fd-mmn-panel, .fd-mmn-drawer, .fd-mmn-dscrim { animation: none; }
592}
593`;
594
595/* ── Demo ─────────────────────────────────────────────────────────────── */
596
597const PRODUCTS = [
598 { src: "/img/lux/perfume-green-800.webp", name: "Thé Vert de Kyoto", meta: "Eau de parfum · 50 ml", price: "€185" },
599 { src: "/img/lux/perfume-peach-800.webp", name: "Pêche de Vigne", meta: "Eau de parfum · 50 ml", price: "€185" },
600 { src: "/img/lux/perfume-north-800.webp", name: "Nord", meta: "Eau de parfum · 50 ml", price: "€195" },
601 { src: "/img/lux/perfume-dusk-800.webp", name: "Crépuscule", meta: "Eau de parfum · 50 ml", price: "€195" },
602];
603
604export function Demo() {
605 return (
606 <div className="fd-mmn-demo">
607 <style>{demoCss}</style>
608 <MegaMenuNav />
609 <main>
610 <section className="fd-mmn-demo-hero">
611 <div className="fd-mmn-demo-copy">
612 <p className="fd-mmn-demo-k">Nº 7 · Extrait de parfum</p>
613 <h1>Ambre <i>Noir</i></h1>
614 <p className="fd-mmn-demo-lede">Labdanum, smoked black tea and a little cracked pepper. Macerated for nine weeks in our cellar on rue de Saintonge before it is bottled by hand.</p>
615 <dl>
616 <div><dt>Size</dt><dd>50 ml</dd></div>
617 <div><dt>Price</dt><dd>€265</dd></div>
618 <div><dt>Batch</dt><dd>0412 of 0600</dd></div>
619 </dl>
620 <a href="#add" className="fd-mmn-demo-btn">Add to bag</a>
621 </div>
622 <img className="fd-mmn-demo-img" src="/img/lux/perfume-noir.webp" alt="Ambre Noir flacon on a dark plinth" width={1200} height={1500} />
623 </section>
624 <section className="fd-mmn-demo-row" aria-label="More eaux de parfum">
625 {PRODUCTS.map((p) => (
626 <a key={p.name} href="#p" className="fd-mmn-demo-p">
627 <img src={p.src} alt={`${p.name} flacon`} width={800} height={1000} loading="lazy" />
628 <b>{p.name}</b>
629 <span>{p.meta}</span>
630 <span>{p.price}</span>
631 </a>
632 ))}
633 </section>
634 </main>
635 </div>
636 );
637}
638
639const demoCss = `
640.fd-mmn-demo { margin: 0; padding: 0; min-height: 100vh; background: #f4f0e8; color: #1b1815; font: 400 15px/1.5 ${sans}; }
641.fd-mmn-demo *, .fd-mmn-demo *::before, .fd-mmn-demo *::after { box-sizing: border-box; }
642.fd-mmn-demo main { display: block; margin: 0; padding: 0 32px 120px; }
643.fd-mmn-demo-hero { display: grid; grid-template-columns: 1fr 1.1fr; gap: 48px; align-items: end; margin: 0; padding: 48px 0 64px; border-bottom: 1px solid #ddd5c8; }
644.fd-mmn-demo-k { margin: 0 0 18px; font: 500 0.75rem/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase; color: #6e1f1c; }
645.fd-mmn-demo h1 { margin: 0; font: 400 clamp(4rem, 11vw, 9.5rem)/0.86 ${serif}; letter-spacing: -0.045em; color: #1b1815; }
646.fd-mmn-demo h1 i { font-style: italic; }
647.fd-mmn-demo-lede { max-width: 42ch; margin: 28px 0 28px; font: 400 1.0625rem/1.55 ${serif}; color: #4a443d; }
648.fd-mmn-demo dl { display: flex; gap: 36px; margin: 0 0 32px; padding: 16px 0; border-top: 1px solid #ddd5c8; border-bottom: 1px solid #ddd5c8; }
649.fd-mmn-demo dt { margin: 0 0 4px; font: 500 0.6875rem/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase; color: #6f675d; }
650.fd-mmn-demo dd { margin: 0; font: 400 1.0625rem/1.2 ${serif}; }
651.fd-mmn-demo-btn { display: inline-block; padding: 16px 36px; font: 500 0.8125rem/1 ${sans}; letter-spacing: 0.12em; text-transform: uppercase; color: #f4f0e8; background: #1b1815; text-decoration: none; }
652.fd-mmn-demo-img { display: block; width: 100%; height: 640px; object-fit: cover; }
653.fd-mmn-demo-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 0; padding: 56px 0 0; }
654.fd-mmn-demo-p { display: flex; flex-direction: column; gap: 2px; color: #1b1815; text-decoration: none; }
655.fd-mmn-demo-p img { display: block; width: 100%; aspect-ratio: 4 / 5; height: auto; object-fit: cover; margin: 0 0 12px; }
656.fd-mmn-demo-p b { font: 400 1.125rem/1.3 ${serif}; }
657.fd-mmn-demo-p span { font: 400 0.8125rem/1.4 ${sans}; color: #6f675d; }
658@media (max-width: 899px) {
659 .fd-mmn-demo main { padding: 0 16px 80px; }
660 .fd-mmn-demo-hero { grid-template-columns: 1fr; gap: 28px; padding: 28px 0 40px; }
661 .fd-mmn-demo-img { height: 420px; order: -1; }
662 .fd-mmn-demo-row { grid-template-columns: 1fr 1fr; gap: 14px; }
663 .fd-mmn-demo dl { gap: 22px; }
664}
665`;

More Navbars. Same file contract.

Floating Capsule Nav, navbars preview: Sticky navbar that springs from a full-width bar into a floating capsule on scroll, with a live status light…

Floating Capsule Nav

Sticky navbar that springs from a full-width bar into a floating capsule on scroll, with a live status light and version chip.

Command Nav, navbars preview: Navbar with command palette: search opens a real ⌘K palette with recents, grouped fuzzy results and a live…

Command Nav

Navbar with command palette: search opens a real ⌘K palette with recents, grouped fuzzy results and a live preview.

App Sidebar Nav, navbars preview: App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a…

App Sidebar Nav

App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a collapsible icon rail.

Questions about Mega Menu Nav

Run npx shadcn@latest add https://freedesignmd.com/r/mega-menu-nav.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Mega menu navbar for a luxury retail site: the panel glides between menus, with link columns, image cards and a sliding underline. It is a free, MIT-licensed React navbars component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. Drawer sub panels use the inert attribute (Chrome 102+, Safari 15.5+, Firefox 112+).

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.