A site header navigation with a pill or underline indicator that glides under the hovered item and a shared dropdown panel that springs open, slides between triggers and cross-fades its sub-links and feature card.
Preview
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add navigation-menuWrites components/ui/navigation-menu.tsx and components/styles/navigation-menu.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { NavigationMenu } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <NavigationMenu />6 );7}Props
Accessibility
Uses the WAI-ARIA disclosure navigation pattern inside <nav aria-label>: items with sub-links are native buttons with aria-expanded and aria-controls pointing at the panel; plain items are links. Left/Right/Home/End move between top-level items, ArrowDown opens a menu and focuses its first link, Up/Down move through panel links, and Escape closes and returns focus to the trigger. The panel closes when focus or a pointer leaves the component. Keyboard Enter/Space toggles the panel even in hover mode. The hover indicator is aria-hidden; reduced motion removes sliding and scaling.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/navigation-menu.module.css";6 7export interface NavigationMenuLink {8 /** Link title */9 label: string;10 /** Short description under the title */11 description?: string;12 /** Link target */13 href?: string;14}15 16export interface NavigationMenuItem {17 /** Top-level label */18 label: string;19 /** Link target for items without sub-links */20 href?: string;21 /** Sub-links shown in the dropdown panel */22 links?: NavigationMenuLink[];23 /** Optional highlighted card shown beside the sub-links */24 feature?: { title: string; description: string; href?: string };25}26 27export interface NavigationMenuProps {28 /** Top-level items */29 items?: NavigationMenuItem[];30 /** Brand text shown on the left (empty hides it) */31 brand?: string;32 /** Call-to-action button label (empty hides it) */33 ctaLabel?: string;34 /** Hover indicator style */35 indicator?: "pill" | "underline";36 /** Open dropdowns on hover (and click) or on click only */37 openOn?: "hover" | "click";38 /** Accent color for the feature card, CTA and highlights */39 accentColor?: string;40 /** Minimum height reserved for the open panel, in pixels */41 minHeight?: number;42 /** Accessible label of the nav landmark */43 ariaLabel?: string;44 /** Called when any link is activated; links with href "#" are not followed */45 onNavigate?: (href: string, label: string) => void;46 /** Optional className merged onto the root */47 className?: string;48 /** Optional inline styles merged onto the root */49 style?: React.CSSProperties;50}51 52const DEFAULT_ITEMS: NavigationMenuItem[] = [53 {54 label: "Products",55 feature: { title: "MotionFoundry 2.3", description: "Forty new animated UI primitives, built from scratch.", href: "#" },56 links: [57 { label: "Analytics", description: "Real-time dashboards for every metric.", href: "#" },58 { label: "Automations", description: "Trigger workflows from any event.", href: "#" },59 { label: "Integrations", description: "Connect the tools you already use.", href: "#" },60 ],61 },62 {63 label: "Resources",64 links: [65 { label: "Documentation", description: "Guides and API references.", href: "#" },66 { label: "Templates", description: "Starter kits for common apps.", href: "#" },67 { label: "Changelog", description: "What shipped this month.", href: "#" },68 { label: "Community", description: "Ask questions, share builds.", href: "#" },69 ],70 },71 { label: "Pricing", href: "#" },72 { label: "Blog", href: "#" },73];74 75const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;76 77export function NavigationMenu({78 items = DEFAULT_ITEMS,79 brand = "Foundry",80 ctaLabel = "Get started",81 indicator = "pill",82 openOn = "hover",83 accentColor = "#6366f1",84 minHeight = 340,85 ariaLabel = "Main",86 onNavigate,87 className = "",88 style,89}: NavigationMenuProps) {90 const reduce = useReducedMotion();91 const baseId = React.useId();92 const [open, setOpen] = React.useState<number | null>(null);93 const [hover, setHover] = React.useState<number | null>(null);94 const [bar, setBar] = React.useState<{ x: number; w: number } | null>(null);95 const [panelX, setPanelX] = React.useState(0);96 const [snap, setSnap] = React.useState(true);97 const placedFor = React.useRef<number | null>(null);98 const rootRef = React.useRef<HTMLDivElement>(null);99 const navRef = React.useRef<HTMLElement>(null);100 const panelRef = React.useRef<HTMLDivElement>(null);101 const topRefs = React.useRef<Array<HTMLElement | null>>([]);102 const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);103 const focusFirst = React.useRef(false);104 const target = hover ?? open;105 106 const clearTimer = () => {107 if (timer.current) clearTimeout(timer.current);108 timer.current = null;109 };110 React.useEffect(() => clearTimer, []);111 112 React.useEffect(() => {113 if (open === null) return;114 const onDown = (e: PointerEvent) => {115 const node = e.target as Node;116 if (!navRef.current?.contains(node) && !panelRef.current?.contains(node)) setOpen(null);117 };118 document.addEventListener("pointerdown", onDown);119 return () => document.removeEventListener("pointerdown", onDown);120 }, [open]);121 122 const scheduleClose = () => {123 setHover(null);124 if (openOn !== "hover") return;125 clearTimer();126 timer.current = setTimeout(() => setOpen(null), 180);127 };128 129 const close = (refocus: boolean) => {130 clearTimer();131 if (refocus && open !== null) topRefs.current[open]?.focus();132 setOpen(null);133 };134 135 const place = React.useCallback(() => {136 const root = rootRef.current;137 const t = target !== null ? topRefs.current[target] : null;138 setBar(t ? { x: t.offsetLeft, w: t.offsetWidth } : null);139 const trig = open !== null ? topRefs.current[open] : null;140 const panel = panelRef.current;141 const fresh = placedFor.current === null;142 placedFor.current = open;143 if (!root || !trig || !panel) return;144 const r = root.getBoundingClientRect();145 const tr = trig.getBoundingClientRect();146 const max = root.clientWidth - panel.offsetWidth;147 setSnap(fresh);148 setPanelX(Math.max(0, Math.min(max, tr.left - r.left + tr.width / 2 - panel.offsetWidth / 2)));149 }, [target, open]);150 151 useIsoLayoutEffect(() => {152 place();153 }, [place]);154 155 React.useEffect(() => {156 const panel = panelRef.current;157 if (!panel || typeof ResizeObserver === "undefined") return;158 const ro = new ResizeObserver(() => place());159 ro.observe(panel);160 if (rootRef.current) ro.observe(rootRef.current);161 if (focusFirst.current) {162 focusFirst.current = false;163 panel.querySelector<HTMLElement>("a")?.focus();164 }165 return () => ro.disconnect();166 }, [open, place]);167 168 const linkClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string | undefined, label: string) => {169 if (!href || href === "#") e.preventDefault();170 onNavigate?.(href ?? "#", label);171 close(false);172 };173 174 const onTopKey = (e: React.KeyboardEvent<HTMLElement>, i: number) => {175 const n = items.length;176 let next = -1;177 if (e.key === "ArrowRight") next = (i + 1) % n;178 else if (e.key === "ArrowLeft") next = (i - 1 + n) % n;179 else if (e.key === "Home") next = 0;180 else if (e.key === "End") next = n - 1;181 else if (e.key === "ArrowDown" && items[i].links?.length) {182 e.preventDefault();183 if (open === i) panelRef.current?.querySelector<HTMLElement>("a")?.focus();184 else {185 focusFirst.current = true;186 setOpen(i);187 }188 return;189 } else if (e.key === "Escape" && open !== null) {190 e.preventDefault();191 close(true);192 return;193 }194 if (next < 0) return;195 e.preventDefault();196 topRefs.current[next]?.focus();197 if (open !== null) setOpen(items[next].links?.length ? next : null);198 };199 200 const onPanelKey = (e: React.KeyboardEvent<HTMLDivElement>) => {201 const links = Array.from(panelRef.current?.querySelectorAll<HTMLElement>("a") ?? []);202 const i = links.indexOf(document.activeElement as HTMLElement);203 let next = -1;204 if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % links.length;205 else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i - 1 + links.length) % links.length;206 else if (e.key === "Home") next = 0;207 else if (e.key === "End") next = links.length - 1;208 else if (e.key === "Escape") {209 e.preventDefault();210 close(true);211 return;212 }213 if (next < 0) return;214 e.preventDefault();215 links[next]?.focus();216 };217 218 const active = open !== null ? items[open] : null;219 const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 36 };220 221 return (222 <div223 ref={rootRef}224 className={`${styles.root} ${className}`.trim()}225 data-indicator={indicator}226 style={{ "--mf-nm-accent": accentColor, minHeight: Math.max(0, minHeight), ...style } as React.CSSProperties}227 onBlur={(e) => {228 if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(null);229 }}230 >231 <nav ref={navRef} aria-label={ariaLabel} className={styles.bar} onPointerLeave={scheduleClose} onPointerEnter={clearTimer}>232 {brand && (233 <a href="#" className={styles.brand} onClick={(e) => linkClick(e, "#", brand)}>234 <span aria-hidden="true" className={styles.logo} />235 {brand}236 </a>237 )}238 <ul className={styles.list} onPointerLeave={() => setHover(null)}>239 <motion.li240 aria-hidden="true"241 className={styles.indicator}242 initial={false}243 animate={bar ? { x: bar.x, width: bar.w, opacity: 1 } : { opacity: 0 }}244 transition={spring}245 />246 {items.map((item, i) => {247 const hasMenu = !!item.links?.length;248 const common = {249 ref: (el: HTMLElement | null) => {250 topRefs.current[i] = el;251 },252 className: styles.top,253 onPointerEnter: () => {254 setHover(i);255 if (openOn === "hover") {256 clearTimer();257 timer.current = setTimeout(() => setOpen(hasMenu ? i : null), open === null ? 90 : 0);258 }259 },260 onFocus: () => setHover(i),261 onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => onTopKey(e, i),262 };263 return (264 <li key={item.label}>265 {hasMenu ? (266 <button267 {...common}268 type="button"269 aria-expanded={open === i}270 aria-controls={`${baseId}-panel`}271 data-open={open === i ? "" : undefined}272 onClick={(e) => {273 clearTimer();274 const toggle = openOn === "click" || e.detail === 0;275 setOpen(toggle && open === i ? null : i);276 }}277 >278 {item.label}279 <svg aria-hidden="true" className={styles.caret} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">280 <path d="m6 9 6 6 6-6" />281 </svg>282 </button>283 ) : (284 <a {...common} href={item.href ?? "#"} onClick={(e) => linkClick(e, item.href, item.label)}>285 {item.label}286 </a>287 )}288 </li>289 );290 })}291 </ul>292 {ctaLabel && (293 <a href="#" className={styles.cta} onClick={(e) => linkClick(e, "#", ctaLabel)}>294 {ctaLabel}295 </a>296 )}297 </nav>298 <AnimatePresence>299 {active && (300 <motion.div301 ref={panelRef}302 id={`${baseId}-panel`}303 className={styles.panel}304 onKeyDown={onPanelKey}305 onPointerEnter={clearTimer}306 onPointerLeave={scheduleClose}307 initial={{ opacity: 0, y: reduce ? 0 : -8, scale: reduce ? 1 : 0.98, x: panelX }}308 animate={{ opacity: 1, y: 0, scale: 1, x: panelX }}309 exit={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.98 }}310 transition={reduce ? { duration: 0.1, x: { duration: 0 } } : { type: "spring", stiffness: 380, damping: 34, x: snap ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 34 } }}311 >312 <AnimatePresence mode="wait" initial={false}>313 <motion.div314 key={open ?? -1}315 className={styles.content}316 initial={{ opacity: 0 }}317 animate={{ opacity: 1 }}318 exit={{ opacity: 0 }}319 transition={{ duration: reduce ? 0 : 0.12 }}320 >321 {active.feature && (322 <a href={active.feature.href ?? "#"} className={styles.feature} onClick={(e) => linkClick(e, active.feature?.href, active.feature?.title ?? "")}>323 <strong>{active.feature.title}</strong>324 <span>{active.feature.description}</span>325 </a>326 )}327 <ul className={styles.links} data-cols={!active.feature && (active.links?.length ?? 0) > 3 ? 2 : 1}>328 {active.links?.map((l) => (329 <li key={l.label}>330 <a href={l.href ?? "#"} className={styles.link} onClick={(e) => linkClick(e, l.href, l.label)}>331 <span className={styles.linkTitle}>{l.label}</span>332 {l.description && <span className={styles.linkDesc}>{l.description}</span>}333 </a>334 </li>335 ))}336 </ul>337 </motion.div>338 </AnimatePresence>339 </motion.div>340 )}341 </AnimatePresence>342 </div>343 );344}