UIOverlays and Menusmotion/react

DropdownMenu

An account-style dropdown menu built on the WAI-ARIA menu button pattern, with icons, shortcut hints, group labels, separators, animated checkbox items and a flyout submenu. It scales in from the trigger with measured, viewport-aware placement and supports full keyboard navigation with typeahead.

Preview

Customize

8 props
236

Installation

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

npx motionfoundry add dropdown-menu

Writes components/ui/dropdown-menu.tsx and components/styles/dropdown-menu.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

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

Props

PropTypeDefault
items

Entries of type 'item' (label, value, icon, shortcut, disabled), 'checkbox' (label, defaultChecked, disabled), 'label', 'separator' and 'submenu' (label, icon, items). Icons are built-in names: user, card, settings, keyboard, users, user-plus, mail, message, plus, help, cloud, logout.

DropdownMenuEntry[]account menu
open

Controlled open state.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called whenever the menu requests to open or close.

(open: boolean) => voidundefined
onSelect

Called with the item's value (or label) when an item is chosen; the menu then closes.

(value: string) => voidundefined
onCheckedChange

Called when a checkbox item is toggled.

(label: string, checked: boolean) => voidundefined
triggerLabel

Label of the trigger button.

string'My account'
side

Preferred side; flips when there is no room in the viewport.

'bottom' | 'top''bottom'
align

Horizontal alignment relative to the trigger, clamped inside the viewport.

'start' | 'center' | 'end''start'
width

Menu width in pixels.

number236
accentColor

Highlight tint, check marks and active icon color.

string'#6366f1'
showIcons

Show item icons.

booleantrue
showShortcuts

Show keyboard shortcut hints.

booleantrue
disabled

Disable the trigger.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Follows the WAI-ARIA menu button pattern: the trigger has aria-haspopup="menu", aria-expanded and aria-controls; the panel is role="menu" labelled by the trigger, with role="menuitem", role="menuitemcheckbox" (aria-checked) and role="separator" children and roving DOM focus. Enter/Space/ArrowDown on the trigger open and focus the first item, ArrowUp focuses the last. Inside: ArrowUp/ArrowDown wrap, Home/End jump, Enter/Space activate (checkbox items toggle without closing), printable keys typeahead, ArrowRight opens the submenu (aria-haspopup="menu", aria-expanded) and ArrowLeft/Escape close it back to its parent, Escape closes the menu and returns focus to the trigger, Tab closes and continues the tab order. Disabled items are skipped and marked aria-disabled. Under prefers-reduced-motion the menus only fade.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/dropdown-menu.tsx
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/dropdown-menu.module.css";7 8export type DropdownMenuIcon = "user" | "card" | "settings" | "keyboard" | "users" | "user-plus" | "mail" | "message" | "plus" | "help" | "cloud" | "logout";9 10export type DropdownMenuItemEntry = { type: "item"; label: string; value?: string; icon?: DropdownMenuIcon; shortcut?: string; disabled?: boolean };11export type DropdownMenuSubEntry = DropdownMenuItemEntry | { type: "separator" };12export type DropdownMenuEntry =13  | DropdownMenuSubEntry14  | { type: "label"; label: string }15  | { type: "checkbox"; label: string; defaultChecked?: boolean; disabled?: boolean }16  | { type: "submenu"; label: string; icon?: DropdownMenuIcon; items: DropdownMenuSubEntry[] };17 18export interface DropdownMenuProps {19  /** Menu entries: items, labels, separators, checkbox items and one level of submenus */20  items?: DropdownMenuEntry[];21  /** Controlled open state */22  open?: boolean;23  /** Initial open state when uncontrolled */24  defaultOpen?: boolean;25  /** Called whenever the menu requests to open or close */26  onOpenChange?: (open: boolean) => void;27  /** Called with the item's value (or label) when an item is chosen */28  onSelect?: (value: string) => void;29  /** Called when a checkbox item is toggled */30  onCheckedChange?: (label: string, checked: boolean) => void;31  /** Label of the trigger button */32  triggerLabel?: string;33  /** Preferred side of the menu (flips when there is no room) */34  side?: "bottom" | "top";35  /** Alignment of the menu relative to the trigger */36  align?: "start" | "center" | "end";37  /** Menu width in pixels */38  width?: number;39  /** Accent color for the highlighted item, checks and focus */40  accentColor?: string;41  /** Show item icons */42  showIcons?: boolean;43  /** Show keyboard shortcut hints */44  showShortcuts?: boolean;45  /** Disable the trigger */46  disabled?: boolean;47  /** Optional className merged onto the root */48  className?: string;49  /** Optional inline styles merged onto the root */50  style?: React.CSSProperties;51}52 53const ICONS: Record<DropdownMenuIcon, string> = {54  user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",55  card: "M3 5h18a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2 10h20",56  settings: "M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6",57  keyboard: "M3 6h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zM6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10",58  users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8",59  "user-plus": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM19 8v6M22 11h-6",60  mail: "M3 5h18v14H3zM3 6l9 7 9-7",61  message: "M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z",62  plus: "M12 5v14M5 12h14",63  help: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01",64  cloud: "M17.5 19H9a7 7 0 1 1 6.7-9h1.8a4.5 4.5 0 1 1 0 9z",65  logout: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9",66};67 68const DEFAULT_ITEMS: DropdownMenuEntry[] = [69  { type: "label", label: "My Account" },70  { type: "item", label: "Profile", icon: "user", shortcut: "⇧⌘P" },71  { type: "item", label: "Billing", icon: "card", shortcut: "⌘B" },72  { type: "item", label: "Settings", icon: "settings", shortcut: "⌘S" },73  { type: "item", label: "Keyboard shortcuts", icon: "keyboard", shortcut: "⌘K" },74  { type: "separator" },75  {76    type: "submenu",77    label: "Invite users",78    icon: "user-plus",79    items: [{ type: "item", label: "Email", icon: "mail" }, { type: "item", label: "Message", icon: "message" }, { type: "separator" }, { type: "item", label: "More…", icon: "plus" }],80  },81  { type: "item", label: "API", icon: "cloud", disabled: true },82  { type: "separator" },83  { type: "label", label: "View" },84  { type: "checkbox", label: "Status bar", defaultChecked: true },85  { type: "checkbox", label: "Activity bar" },86  { type: "separator" },87  { type: "item", label: "Log out", icon: "logout", shortcut: "⇧⌘Q" },88];89 90const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];91const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;92const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(v, Math.max(lo, hi)));93const isNav = (e: DropdownMenuEntry) => (e.type === "item" || e.type === "checkbox" || e.type === "submenu") && !("disabled" in e && e.disabled);94const labelOf = (e: DropdownMenuEntry) => ("label" in e ? e.label : "");95 96function readThemeVars(el: Element | null): Record<string, string> {97  const out: Record<string, string> = {};98  if (!el) return out;99  const cs = getComputedStyle(el);100  for (const t of THEME_TOKENS) {101    const v = cs.getPropertyValue(t).trim();102    if (v) out[t] = v;103  }104  return out;105}106 107function Icon({ d, className }: { d: string; className?: string }) {108  return (109    <svg aria-hidden="true" className={className} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">110      <path d={d} />111    </svg>112  );113}114 115export function DropdownMenu({116  items = DEFAULT_ITEMS,117  open: openProp,118  defaultOpen = false,119  onOpenChange,120  onSelect,121  onCheckedChange,122  triggerLabel = "My account",123  side = "bottom",124  align = "start",125  width = 236,126  accentColor = "#6366f1",127  showIcons = true,128  showShortcuts = true,129  disabled = false,130  className = "",131  style,132}: DropdownMenuProps) {133  const reduce = useReducedMotion();134  const id = React.useId();135  const [inner, setInner] = React.useState(defaultOpen);136  const open = !disabled && (openProp !== undefined ? openProp : inner);137  const [active, setActive] = React.useState(-1);138  const [subOpen, setSubOpen] = React.useState(false);139  const [subActive, setSubActive] = React.useState(-1);140  const [subLeft, setSubLeft] = React.useState(false);141  const [checked, setChecked] = React.useState<Record<number, boolean>>(() => {142    const init: Record<number, boolean> = {};143    items.forEach((e, i) => e.type === "checkbox" && (init[i] = !!e.defaultChecked));144    return init;145  });146  const [mounted, setMounted] = React.useState(false);147  const [pos, setPos] = React.useState<{ left: number; top: number; side: "bottom" | "top" } | null>(null);148  const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});149  const triggerRef = React.useRef<HTMLButtonElement>(null);150  const menuRef = React.useRef<HTMLDivElement>(null);151  const subRef = React.useRef<HTMLDivElement>(null);152  const itemRefs = React.useRef<Array<HTMLDivElement | null>>([]);153  const subRefs = React.useRef<Array<HTMLDivElement | null>>([]);154  const typed = React.useRef({ text: "", time: 0 });155 156  const subIndex = items.findIndex((e) => e.type === "submenu");157  const subEntry = items[subIndex];158  const subItems = subEntry && subEntry.type === "submenu" ? subEntry.items : [];159  const nav = items.map((e, i) => (isNav(e) ? i : -1)).filter((i) => i >= 0);160  const subNav = subItems.map((e, i) => (isNav(e) ? i : -1)).filter((i) => i >= 0);161 162  React.useEffect(() => setMounted(true), []);163 164  const setOpen = React.useCallback(165    (next: boolean, focus: "first" | "last" | "none" = "none") => {166      if (openProp === undefined) setInner(next);167      onOpenChange?.(next);168      setSubOpen(false);169      setSubActive(-1);170      setActive(next && focus !== "none" ? (focus === "first" ? nav[0] ?? -1 : nav[nav.length - 1] ?? -1) : -1);171    },172    [openProp, onOpenChange, nav.join()],173  );174  const setOpenRef = React.useRef(setOpen);175  setOpenRef.current = setOpen;176  const close = (refocus = true) => {177    setOpen(false);178    if (refocus) triggerRef.current?.focus();179  };180 181  const place = React.useCallback(() => {182    const t = triggerRef.current;183    const m = menuRef.current;184    if (!t || !m) return;185    const r = t.getBoundingClientRect();186    const [w, h] = [m.offsetWidth, m.offsetHeight];187    const below = window.innerHeight - r.bottom;188    const s = side === "bottom" ? (below < h + 12 && r.top > below ? "top" : "bottom") : r.top < h + 12 && below > r.top ? "bottom" : "top";189    const raw = align === "start" ? r.left : align === "end" ? r.right - w : r.left + r.width / 2 - w / 2;190    setPos({ side: s, left: clamp(raw, 8, window.innerWidth - w - 8), top: s === "bottom" ? r.bottom + 6 : r.top - 6 - h });191  }, [side, align]);192 193  useIsoLayoutEffect(() => {194    if (!open) return;195    setThemeVars(readThemeVars(triggerRef.current));196    place();197    const onDown = (e: PointerEvent) => {198      const t = e.target as Node;199      if (!triggerRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpenRef.current(false);200    };201    window.addEventListener("resize", place);202    window.addEventListener("scroll", place, true);203    document.addEventListener("pointerdown", onDown);204    return () => {205      window.removeEventListener("resize", place);206      window.removeEventListener("scroll", place, true);207      document.removeEventListener("pointerdown", onDown);208    };209  }, [open, place]);210 211  // Submenu collision: open to the left when there is no room on the right.212  useIsoLayoutEffect(() => {213    const anchor = itemRefs.current[subIndex];214    if (!subOpen || !anchor || !subRef.current) return;215    setSubLeft(anchor.getBoundingClientRect().right + subRef.current.offsetWidth + 12 > window.innerWidth);216  }, [subOpen, subIndex]);217 218  // Roving focus: DOM focus follows the active item.219  React.useEffect(() => {220    if (!open) return;221    const el = subOpen && subActive >= 0 ? subRefs.current[subActive] : active >= 0 ? itemRefs.current[active] : menuRef.current;222    el?.focus({ preventScroll: true });223  }, [open, active, subOpen, subActive]);224 225  const openSub = (focusFirst: boolean) => {226    setActive(subIndex);227    setSubOpen(true);228    setSubActive(focusFirst ? subNav[0] ?? -1 : -1);229  };230  const closeSub = () => {231    setSubOpen(false);232    setSubActive(-1);233  };234 235  const activate = (i: number, inSub: boolean) => {236    const e = inSub ? subItems[i] : items[i];237    if (!e) return;238    if (e.type === "item" && !e.disabled) {239      onSelect?.(e.value ?? e.label);240      close();241    } else if (e.type === "checkbox" && !e.disabled) {242      const next = !checked[i];243      setChecked((c) => ({ ...c, [i]: next }));244      onCheckedChange?.(e.label, next);245    } else if (e.type === "submenu") openSub(true);246  };247 248  const onMenuKey = (e: React.KeyboardEvent<HTMLDivElement>) => {249    const k = e.key;250    const inSub = subOpen && subActive >= 0;251    const list = inSub ? subNav : nav;252    const cur = inSub ? subActive : active;253    const setCur = (i: number) => {254      if (inSub) setSubActive(i);255      else {256        setActive(i);257        closeSub();258      }259    };260    const n = list.length;261    const at = list.indexOf(cur);262    if (k === "ArrowDown" || k === "ArrowUp") {263      e.preventDefault();264      if (n) setCur(list[at < 0 ? (k === "ArrowDown" ? 0 : n - 1) : (at + (k === "ArrowDown" ? 1 : -1) + n) % n]);265    } else if (k === "Home" || k === "End") {266      e.preventDefault();267      if (n) setCur(list[k === "Home" ? 0 : n - 1]);268    } else if (k === "ArrowRight" && !inSub && cur === subIndex && cur >= 0) {269      e.preventDefault();270      openSub(true);271    } else if ((k === "ArrowLeft" && inSub) || (k === "Escape" && subOpen)) {272      e.preventDefault();273      closeSub();274    } else if (k === "Escape") {275      e.preventDefault();276      close();277    } else if (k === "Enter" || k === " ") {278      e.preventDefault();279      if (cur >= 0) activate(cur, inSub);280    } else if (k === "Tab") {281      close();282    } else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {283      const t = typed.current;284      const now = performance.now();285      t.text = now - t.time > 500 ? k : t.text + k;286      t.time = now;287      const source = inSub ? subItems : items;288      const start = Math.max(0, at) + (t.text.length === 1 ? 1 : 0);289      for (let j = 0; j < n; j++) {290        const idx = list[(start + j) % n];291        if (labelOf(source[idx]).toLowerCase().startsWith(t.text.toLowerCase())) return setCur(idx);292      }293    }294  };295 296  const onTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {297    if (["ArrowDown", "Enter", " "].includes(e.key) || e.key === "ArrowUp") {298      e.preventDefault();299      setOpen(true, e.key === "ArrowUp" ? "last" : "first");300    }301  };302 303  const renderEntry = (e: DropdownMenuEntry, i: number, inSub: boolean) => {304    const key = `${inSub ? "s" : "m"}${i}`;305    if (e.type === "separator") return <div key={key} role="separator" className={styles.separator} />;306    if (e.type === "label") return <div key={key} role="presentation" className={styles.groupLabel}>{e.label}</div>;307    const isActive = inSub ? subActive === i : active === i && (!subOpen || subActive < 0);308    const off = "disabled" in e && !!e.disabled;309    const refs = inSub ? subRefs : itemRefs;310    const common = {311      ref: (el: HTMLDivElement | null) => {312        refs.current[i] = el;313      },314      tabIndex: -1,315      "aria-disabled": off || undefined,316      "data-active": isActive || (!inSub && e.type === "submenu" && subOpen) ? "" : undefined,317      className: styles.item,318      onClick: () => !off && activate(i, inSub),319      onPointerMove: () => {320        if (off) return;321        if (inSub) return subActive !== i && setSubActive(i);322        if (active !== i) setActive(i);323        if (e.type !== "submenu") return subOpen && closeSub();324        if (!subOpen) openSub(false);325        else if (subActive >= 0) setSubActive(-1);326      },327    };328    const icon = "icon" in e && e.icon && showIcons ? <Icon d={ICONS[e.icon]} className={styles.icon} /> : null;329    if (e.type === "checkbox") {330      return (331        <div key={key} role="menuitemcheckbox" aria-checked={!!checked[i]} {...common}>332          <span className={styles.check} aria-hidden="true">333            {checked[i] && (334              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">335                <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.2 }} />336              </svg>337            )}338          </span>339          <span className={styles.text}>{e.label}</span>340        </div>341      );342    }343    if (e.type === "submenu") {344      return (345        <div key={key} role="none" className={styles.subWrap}>346          <div role="menuitem" aria-haspopup="menu" aria-expanded={subOpen} aria-controls={subOpen ? `${id}-sub` : undefined} {...common}>347            {icon}348            <span className={styles.text}>{e.label}</span>349            <Icon d="m9 6 6 6-6 6" className={styles.chevron} />350          </div>351          <AnimatePresence>352            {subOpen && (353              <motion.div354                ref={subRef}355                id={`${id}-sub`}356                role="menu"357                aria-label={e.label}358                className={styles.menu}359                data-sub={subLeft ? "left" : "right"}360                style={{ transformOrigin: subLeft ? "top right" : "top left" }}361                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, x: subLeft ? 6 : -6 }}362                animate={{ opacity: 1, scale: 1, x: 0 }}363                exit={{ opacity: 0, scale: reduce ? 1 : 0.97 }}364                transition={{ duration: reduce ? 0.06 : 0.14, ease: [0.16, 1, 0.3, 1] }}365              >366                {e.items.map((s, j) => renderEntry(s, j, true))}367              </motion.div>368            )}369          </AnimatePresence>370        </div>371      );372    }373    return (374      <div key={key} role="menuitem" {...common}>375        {icon}376        <span className={styles.text}>{e.label}</span>377        {showShortcuts && e.shortcut && <kbd className={styles.shortcut}>{e.shortcut}</kbd>}378      </div>379    );380  };381 382  const fromTop = (pos?.side ?? side) === "bottom";383  const originX = align === "start" ? "left" : align === "end" ? "right" : "center";384 385  return (386    <div className={`${styles.root} ${className}`.trim()} style={{ "--mf-dm-accent": accentColor, ...style } as React.CSSProperties}>387      <button388        ref={triggerRef}389        id={`${id}-trigger`}390        type="button"391        aria-haspopup="menu"392        aria-expanded={open}393        aria-controls={open ? `${id}-menu` : undefined}394        disabled={disabled}395        className={styles.trigger}396        onClick={() => setOpen(!open)}397        onKeyDown={onTriggerKey}398      >399        <span aria-hidden="true" className={styles.avatar}>400          AL401        </span>402        {triggerLabel}403        <motion.svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" animate={{ rotate: open ? 180 : 0 }} transition={{ duration: reduce ? 0 : 0.2 }}>404          <path d="m6 9 6 6 6-6" />405        </motion.svg>406      </button>407      {mounted &&408        createPortal(409          <AnimatePresence>410            {open && (411              <motion.div412                ref={menuRef}413                id={`${id}-menu`}414                role="menu"415                aria-labelledby={`${id}-trigger`}416                aria-orientation="vertical"417                tabIndex={-1}418                className={`${styles.menu} ${styles.panel}`}419                style={{ ...themeVars, "--mf-dm-accent": accentColor, "--mf-dm-w": `${width}px`, left: pos?.left ?? 0, top: pos?.top ?? 0, visibility: pos ? "visible" : "hidden", transformOrigin: `${fromTop ? "top" : "bottom"} ${originX}` } as React.CSSProperties}420                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: fromTop ? -6 : 6 }}421                animate={{ opacity: 1, scale: 1, y: 0 }}422                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: fromTop ? -4 : 4 }}423                transition={{ duration: reduce ? 0.08 : 0.16, ease: [0.16, 1, 0.3, 1] }}424                onKeyDown={onMenuKey}425                onPointerLeave={() => !subOpen && setActive(-1)}426              >427                {items.map((e, i) => renderEntry(e, i, false))}428              </motion.div>429            )}430          </AnimatePresence>,431          document.body,432        )}433    </div>434  );435}