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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add dropdown-menuWrites 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.
1import { DropdownMenu } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <DropdownMenu />6 );7}Props
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
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}