UINavigationmotion/react

NavigationMenu

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 props
340

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add navigation-menu

Writes 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.

example.tsx
1import { NavigationMenu } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <NavigationMenu />6  );7}

Props

PropTypeDefault
items

Top-level items; items with links get a dropdown panel.

{ label: string; href?: string; links?: { label: string; description?: string; href?: string }[]; feature?: { title: string; description: string; href?: string } }[]Products / Resources / Pricing / Blog
brand

Brand text on the left; empty string hides it.

string'Foundry'
ctaLabel

Call-to-action label on the right; empty string hides it.

string'Get started'
indicator

Style of the gliding hover indicator.

'pill' | 'underline''pill'
openOn

Open panels on pointer hover (click also works) or on click only.

'hover' | 'click''hover'
accentColor

Color of the feature card, CTA and link highlights.

string'#6366f1'
minHeight

Minimum height of the root in pixels so the open panel has room; use 0 in a real header.

number340
ariaLabel

Accessible label of the nav landmark.

string'Main'
onNavigate

Called when any link is activated. Links with href '#' are not followed.

(href: string, label: string) => voidundefined
className

Class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/navigation-menu.tsx
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}