A desktop-app style menubar (File, Edit, View, Profiles) whose menus fade and lift in, switch on hover once one is open, and support shortcuts, separators, disabled items, and checkbox items.
Preview
Customize
5 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add menubarWrites components/ui/menubar.tsx and components/styles/menubar.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Menubar } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Menubar />6 );7}Props
Accessibility
Implements the WAI-ARIA menubar pattern: a role="menubar" with roving tabindex across role="menuitem" triggers (aria-haspopup, aria-expanded, aria-controls). Left/Right move between menus (switching the open menu), Down/Enter/Space open a menu on its first item, Up opens on its last, Home/End jump. Inside a menu, Up/Down/Home/End move focus, letters jump by typeahead, Enter/Space activate, Escape closes and returns focus to the trigger, and Tab closes. Checkbox items use role="menuitemcheckbox" with aria-checked and keep the menu open; disabled items are aria-disabled and skipped. Reduced motion removes the open animation.
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/menubar.module.css";6 7export type MenubarEntry =8 | { type?: "item"; label: string; shortcut?: string; disabled?: boolean }9 | { type: "checkbox"; label: string; shortcut?: string; checked?: boolean }10 | { type: "separator" };11 12export interface MenubarMenu {13 /** Top-level menu label */14 label: string;15 /** Entries shown in the dropdown */16 items: MenubarEntry[];17}18 19export interface MenubarProps {20 /** Menus from left to right */21 menus?: MenubarMenu[];22 /** App name shown before the menus (empty hides it) */23 appName?: string;24 /** Show keyboard shortcut hints */25 showShortcuts?: boolean;26 /** Tighter item spacing */27 dense?: boolean;28 /** While a menu is open, hovering another trigger switches to it */29 switchOnHover?: boolean;30 /** Accent color for highlighted items and checks */31 accentColor?: string;32 /** Called when an item is chosen, with the menu and item labels */33 onSelect?: (menu: string, item: string) => void;34 /** Called when a checkbox item is toggled */35 onCheckedChange?: (menu: string, item: string, checked: boolean) => void;36 /** Optional className merged onto the root */37 className?: string;38 /** Optional inline styles merged onto the root */39 style?: React.CSSProperties;40}41 42const DEFAULT_MENUS: MenubarMenu[] = [43 {44 label: "File",45 items: [46 { label: "New Tab", shortcut: "⌘T" },47 { label: "New Window", shortcut: "⌘N" },48 { label: "New Incognito Window", disabled: true },49 { type: "separator" },50 { label: "Share…" },51 { label: "Print…", shortcut: "⌘P" },52 ],53 },54 {55 label: "Edit",56 items: [57 { label: "Undo", shortcut: "⌘Z" },58 { label: "Redo", shortcut: "⇧⌘Z" },59 { type: "separator" },60 { label: "Cut", shortcut: "⌘X" },61 { label: "Copy", shortcut: "⌘C" },62 { label: "Paste", shortcut: "⌘V" },63 ],64 },65 {66 label: "View",67 items: [68 { type: "checkbox", label: "Always Show Bookmarks Bar", checked: true },69 { type: "checkbox", label: "Always Show Full URLs" },70 { type: "separator" },71 { label: "Reload", shortcut: "⌘R" },72 { label: "Toggle Fullscreen", shortcut: "⌃⌘F" },73 ],74 },75 {76 label: "Profiles",77 items: [{ label: "Andy" }, { label: "Benoit" }, { label: "Luis" }, { type: "separator" }, { label: "Edit…" }, { label: "Add Profile…" }],78 },79];80 81type Checks = Record<string, boolean>;82const checkKey = (menu: string, item: string) => `${menu}\u0000${item}`;83 84function initialChecks(menus: MenubarMenu[]): Checks {85 const out: Checks = {};86 for (const m of menus) for (const it of m.items) if (it.type === "checkbox") out[checkKey(m.label, it.label)] = !!it.checked;87 return out;88}89 90export function Menubar({91 menus = DEFAULT_MENUS,92 appName = "Foundry",93 showShortcuts = true,94 dense = false,95 switchOnHover = true,96 accentColor = "#6366f1",97 onSelect,98 onCheckedChange,99 className = "",100 style,101}: MenubarProps) {102 const reduceMotion = useReducedMotion();103 const baseId = React.useId();104 const rootRef = React.useRef<HTMLDivElement>(null);105 const triggerRefs = React.useRef<(HTMLButtonElement | null)[]>([]);106 const [open, setOpen] = React.useState<number | null>(null);107 const [focusIndex, setFocusIndex] = React.useState(0); // roving tabindex among triggers108 const [checks, setChecks] = React.useState<Checks>(() => initialChecks(menus));109 const pendingItemFocus = React.useRef<"first" | "last" | null>(null);110 111 // Close on outside pointer down.112 React.useEffect(() => {113 if (open === null) return;114 const onDown = (e: PointerEvent) => {115 if (!rootRef.current?.contains(e.target as Node)) setOpen(null);116 };117 document.addEventListener("pointerdown", onDown);118 return () => document.removeEventListener("pointerdown", onDown);119 }, [open]);120 121 const menuItems = (menuIndex: number) =>122 Array.from(rootRef.current?.querySelectorAll<HTMLElement>(`[data-menu="${menuIndex}"] [role^="menuitem"]:not([aria-disabled="true"])`) ?? []);123 124 // After a menu opens from the keyboard, move focus into it.125 React.useEffect(() => {126 if (open === null || !pendingItemFocus.current) return;127 const where = pendingItemFocus.current;128 pendingItemFocus.current = null;129 const id = requestAnimationFrame(() => {130 const items = menuItems(open);131 (where === "last" ? items[items.length - 1] : items[0])?.focus();132 });133 return () => cancelAnimationFrame(id);134 }, [open]);135 136 const openMenu = (index: number, focusItem: "first" | "last" | null = null) => {137 pendingItemFocus.current = focusItem;138 setFocusIndex(index);139 setOpen(index);140 };141 142 const close = (refocus = true) => {143 const index = open;144 setOpen(null);145 if (refocus && index !== null) triggerRefs.current[index]?.focus();146 };147 148 const moveTrigger = (delta: number, keepOpen: boolean) => {149 const next = (focusIndex + delta + menus.length) % menus.length;150 setFocusIndex(next);151 triggerRefs.current[next]?.focus();152 if (keepOpen) openMenu(next, "first");153 };154 155 const onTriggerKeyDown = (e: React.KeyboardEvent, index: number) => {156 if (e.key === "ArrowRight" || e.key === "ArrowLeft") {157 e.preventDefault();158 moveTrigger(e.key === "ArrowRight" ? 1 : -1, open !== null);159 } else if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {160 e.preventDefault();161 openMenu(index, "first");162 } else if (e.key === "ArrowUp") {163 e.preventDefault();164 openMenu(index, "last");165 } else if (e.key === "Home" || e.key === "End") {166 e.preventDefault();167 const target = e.key === "Home" ? 0 : menus.length - 1;168 setFocusIndex(target);169 triggerRefs.current[target]?.focus();170 } else if (e.key === "Escape") {171 close();172 }173 };174 175 const onMenuKeyDown = (e: React.KeyboardEvent, menuIndex: number) => {176 const items = menuItems(menuIndex);177 const current = items.indexOf(document.activeElement as HTMLElement);178 const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus();179 switch (e.key) {180 case "ArrowDown":181 e.preventDefault();182 focusAt(current + 1);183 break;184 case "ArrowUp":185 e.preventDefault();186 focusAt(current - 1);187 break;188 case "Home":189 e.preventDefault();190 focusAt(0);191 break;192 case "End":193 e.preventDefault();194 focusAt(items.length - 1);195 break;196 case "ArrowRight":197 case "ArrowLeft":198 e.preventDefault();199 moveTrigger(e.key === "ArrowRight" ? 1 : -1, true);200 break;201 case "Escape":202 e.preventDefault();203 close();204 break;205 case "Tab":206 setOpen(null);207 break;208 default:209 // Typeahead: jump to the next item starting with the typed letter.210 if (e.key.length === 1 && /\S/.test(e.key)) {211 const k = e.key.toLowerCase();212 const order = [...items.slice(current + 1), ...items.slice(0, current + 1)];213 order.find((el) => el.textContent?.trim().toLowerCase().startsWith(k))?.focus();214 }215 }216 };217 218 const choose = (menu: MenubarMenu, entry: MenubarEntry) => {219 if (entry.type === "separator") return;220 if (entry.type === "checkbox") {221 const key = checkKey(menu.label, entry.label);222 const next = !checks[key];223 setChecks((c) => ({ ...c, [key]: next }));224 onCheckedChange?.(menu.label, entry.label, next);225 return; // checkbox items keep the menu open226 }227 if (entry.disabled) return;228 onSelect?.(menu.label, entry.label);229 close();230 };231 232 const transition = reduceMotion ? { duration: 0 } : { duration: 0.16, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] };233 234 return (235 <div236 ref={rootRef}237 className={`${styles.root} ${dense ? styles.dense : ""} ${className}`.trim()}238 style={{ "--mf-mb-accent": accentColor, ...style } as React.CSSProperties}239 >240 <div role="menubar" aria-label={appName || "Menu bar"} className={styles.bar}>241 {appName && (242 <span className={styles.app} aria-hidden="true">243 <span className={styles.logo} />244 {appName}245 </span>246 )}247 {menus.map((menu, index) => {248 const isOpen = open === index;249 const menuId = `${baseId}-menu-${index}`;250 return (251 <div key={menu.label} className={styles.menuWrap} data-menu={index}>252 <button253 ref={(el) => {254 triggerRefs.current[index] = el;255 }}256 type="button"257 role="menuitem"258 aria-haspopup="menu"259 aria-expanded={isOpen}260 aria-controls={isOpen ? menuId : undefined}261 tabIndex={index === focusIndex ? 0 : -1}262 className={styles.trigger}263 data-open={isOpen || undefined}264 onClick={() => (isOpen ? close(false) : openMenu(index))}265 onPointerEnter={() => {266 if (switchOnHover && open !== null && open !== index) openMenu(index);267 }}268 onKeyDown={(e) => onTriggerKeyDown(e, index)}269 >270 {menu.label}271 </button>272 <AnimatePresence>273 {isOpen && (274 <motion.div275 id={menuId}276 role="menu"277 aria-label={menu.label}278 className={styles.menu}279 initial={{ opacity: 0, y: -4, scale: 0.98 }}280 animate={{ opacity: 1, y: 0, scale: 1 }}281 exit={{ opacity: 0, y: -4, scale: 0.98 }}282 transition={transition}283 onKeyDown={(e) => onMenuKeyDown(e, index)}284 >285 {menu.items.map((entry, i) => {286 if (entry.type === "separator") return <div key={`sep-${i}`} role="separator" className={styles.separator} />;287 const isCheckbox = entry.type === "checkbox";288 const checked = isCheckbox && checks[checkKey(menu.label, entry.label)];289 const disabled = !isCheckbox && !!entry.disabled;290 return (291 <div292 key={entry.label}293 role={isCheckbox ? "menuitemcheckbox" : "menuitem"}294 aria-checked={isCheckbox ? !!checked : undefined}295 aria-disabled={disabled || undefined}296 tabIndex={-1}297 className={styles.item}298 onClick={() => choose(menu, entry)}299 onKeyDown={(e) => {300 if (e.key === "Enter" || e.key === " ") {301 e.preventDefault();302 choose(menu, entry);303 }304 }}305 onPointerEnter={(e) => {306 if (!disabled) e.currentTarget.focus();307 }}308 >309 <span className={styles.check} aria-hidden="true">310 {checked && (311 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">312 <path d="m5 12.5 4.5 4.5L19 7" />313 </svg>314 )}315 </span>316 <span className={styles.label}>{entry.label}</span>317 {showShortcuts && entry.shortcut && <kbd className={styles.shortcut}>{entry.shortcut}</kbd>}318 </div>319 );320 })}321 </motion.div>322 )}323 </AnimatePresence>324 </div>325 );326 })}327 </div>328 </div>329 );330}