UINavigationmotion/react

Menubar

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 props

Installation

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

npx motionfoundry add menubar

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

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

Props

PropTypeDefault
menus

Menus: { label, items: ({ label, shortcut?, disabled? } | { type: 'checkbox', label, checked? } | { type: 'separator' })[] }.

MenubarMenu[]File / Edit / View / Profiles
appName

App name before the menus; also the menubar's accessible label (empty hides it).

string'Foundry'
showShortcuts

Show keyboard shortcut hints.

booleantrue
dense

Tighter item spacing.

booleanfalse
switchOnHover

While a menu is open, hovering another trigger switches to it.

booleantrue
accentColor

Highlight, check mark, and focus color.

string'#6366f1'
onSelect

Called when an item is chosen.

(menu: string, item: string) => voidundefined
onCheckedChange

Called when a checkbox item toggles.

(menu: string, item: string, checked: boolean) => voidundefined
className

Merged onto the root.

string''
style

Merged onto the root.

React.CSSPropertiesundefined

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

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