MenubarMOL
A persistent, desktop-style menu bar - File, Edit, View - where every trigger opens a dropdown menu with all it can do: submenus (here three levels deep), checkbox and radio items that switch on click, shortcuts, icons and disabled items. One tab stop for the bar, ← / → between the menus, and once a menu is open, hovering the others switches between them. After shadcn/ui's Menubar.
On this page (10)
shadcn's demo: File, Edit, View, Profiles. Click a trigger (or Tab to the bar, then ↓ / Enter); ← / → move between the menus, and while one is open, hovering another trigger switches to it. Shortcuts, a disabled item, checkbox and radio items, inset items - and submenus.
File → Share → More → Social and Edit → Find → Replace → In scope: every submenu is a .dropdown-sub inside the menu above it. Hover or → opens, ← / Esc closes one level; near the viewport edge a submenu opens to the other side.
§Checkbox and radio items
Click (or Enter / Space) toggles a checkbox and switches a radio - the menu stays open. Each change fires dropdown:select; the line under the bar reads the live state.
§Icons
An svg first in an item is its icon; submenu triggers take one too.
§Disabled
A disabled trigger is skipped by ← / →; disabled items (disabled or aria-disabled) are skipped inside menus, and a disabled submenu trigger never opens.
§Variants and sizes
data-variant='muted' or 'ghost' on the bar; data-size='sm' / 'lg' scales the triggers (the menus keep their own data-size).
§Right to left
In dir='rtl' the bar runs right to left, menus align to their trigger's right edge, submenus open to the left, and ← / → swap.
§States
Named states via the shared State API on the bar:
default- every menu closedopen- one menu open:{ menu: 'menu-id' }or{ menu: 0 }(default: the first)
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/menubar-{state}.png.
Machine contract - verified against menubar.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | A menu of the bar is open (setState('open', { menu })); observed from the bar's state name. |
§CSS view file
/* -- Menubar component ----------------------------------------------- *//* A horizontal bar of menu triggers - File, Edit, View - each opening a dropdown menu (dropdown.css / dropdown.js, submenus included). The bar is the menubar; menubar.js adds moving between the menus. */@layer components { .menubar { display: flex; align-items: center; gap: 0.125rem; width: fit-content; max-width: 100%; padding: 0.25rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--background); box-shadow: var(--shadow-2xs); &[data-variant="ghost"] { border-color: transparent; box-shadow: none; padding: 0; } &[data-variant="muted"] { background: var(--muted); border-color: transparent; box-shadow: none; } /* -- Sizes: the triggers scale (the menus keep their own data-size) */ &[data-size="sm"] .menubar-trigger { height: 1.625rem; padding-inline: 0.5rem; font-size: 0.8125rem; } &[data-size="lg"] .menubar-trigger { height: 2.25rem; padding-inline: 0.875rem; font-size: 0.9375rem; } } .menubar-trigger { display: inline-flex; align-items: center; gap: 0.375rem; height: 1.875rem; padding-inline: 0.625rem; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--foreground); font: inherit; font-size: 0.875rem; font-weight: 500; cursor: default; user-select: none; outline: none; & svg { width: 1rem; height: 1rem; flex-shrink: 0; } &:hover, &:focus-visible { background-color: var(--accent); color: var(--accent-foreground); } &[aria-expanded="true"] { background-color: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } &:disabled, &[aria-disabled="true"] { opacity: 0.5; pointer-events: none; } } /* the menus hang just under the bar */ .menubar > .dropdown-content { margin-top: 0.5rem; } .menubar:dir(rtl) > .dropdown-content:not(.dropdown-sub-content) { left: auto; right: anchor(right); }}@media (prefers-contrast: more) { @layer components { .menubar { border-color: var(--foreground); } .menubar-trigger[aria-expanded="true"] { outline: 1px solid var(--foreground); } }}@media (forced-colors: active) { @layer components { .menubar { border-color: CanvasText; } .menubar-trigger:is(:hover, :focus-visible, [aria-expanded="true"]) { forced-color-adjust: none; background: Highlight; color: HighlightText; } }}§JS view file
/* -- Menubar component ------------------------------------------------ */// A .menubar[role="menubar"] of .menubar-trigger buttons, each wired to a// dropdown menu by dropdown.js (data-dropdown-trigger - submenus, checkbox// and radio items included). This module adds the menubar pattern (WAI-ARIA// APG): one tab stop for the whole bar (roving tabindex), Left / Right /// Home / End between the triggers, Down / Up / Enter opening a menu, Left /// Right inside an open menu moving to the neighbouring menu, and - while a// menu is open - hovering another trigger switching to its menu. Needs// dropdown.js (all.js carries both).// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, safeShowPopover } from '../../shared/state-api.js';const df$ = defussGlobals();/** default = every menu closed; open = one menu open ({ menu: id | index }). */const menubarStates = ['default', 'open'];const triggersOf = (bar) => Array.from(bar.querySelectorAll('.menubar-trigger')).filter((t) => t.closest('.menubar') === bar && !t.disabled && t.getAttribute('aria-disabled') !== 'true');const menuOf = (trigger) => document.getElementById(trigger.dataset.dropdownTrigger || trigger.getAttribute('popovertarget') || '');const openMenuOf = (bar) => triggersOf(bar).map(menuOf).find((m) => m?.matches(':popover-open')) ?? null;function setRoving(bar, active) { triggersOf(bar).forEach((t) => t.setAttribute('tabindex', t === active ? '0' : '-1'));}/** Open a trigger's menu (closing the other - popover="auto" does that * natively); focus its first item unless `quiet` (hover switching). */function openMenu(bar, trigger, quiet = false) { const menu = menuOf(trigger); if (!menu) return; setRoving(bar, trigger); if (menu.matches(':popover-open')) { if (!quiet) focusItem(menu); return; } menu._noFocus = quiet; if (quiet) trigger.focus({ preventScroll: true }); safeShowPopover(menu);}/** Focus the first (or last) own item of an open menu. */function focusItem(menu, last = false) { const own = Array.from(menu.querySelectorAll('[role^="menuitem"]')).filter((x) => x.closest('[role="menu"]') === menu && !(x as HTMLButtonElement).disabled && x.getAttribute('aria-disabled') !== 'true'); own.forEach((x) => x.removeAttribute('data-highlighted')); const item = last ? own.at(-1) : own[0]; item?.setAttribute('data-highlighted', ''); (item as HTMLElement | undefined)?.focus({ preventScroll: true });}function triggerStateChange(bar, stateName, config) { switch (stateName) { case 'default': { const open = openMenuOf(bar); if (open) { try { open.hidePopover(); } catch { /* closed */ } } break; } case 'open': { const ts = triggersOf(bar); const t = typeof config.menu === 'number' ? ts[config.menu] : config.menu ? ts.find((x) => x.dataset.dropdownTrigger === config.menu) : ts[0]; if (t) openMenu(bar, t); break; } }}/** Registry-level API; pass the .menubar explicitly. Unknown names throw. */export const menubarApi = { setState(bar, stateName, config = {}) { if (!menubarStates.includes(stateName)) { throw new Error(`menubar: unknown state "${stateName}" (supported: ${menubarStates.join(', ')})`); } bar._stateConfig = config; bar.dataset.stateName = stateName; triggerStateChange(bar, stateName, config); }, getState(bar) { const open = openMenuOf(bar); return { name: open ? 'open' : 'default', config: { ...bar._stateConfig, menu: open?.id ?? null } }; },};df$.menubarApi = menubarApi;df$.menubarStates = menubarStates;function init() { document.querySelectorAll('.menubar:not([data-init])').forEach((bar) => { bar.dataset.init = ''; if (!bar.hasAttribute('role')) bar.setAttribute('role', 'menubar'); bar.api = { setState: (stateName, config) => menubarApi.setState(bar, stateName, config), getState: () => menubarApi.getState(bar), }; const triggers = triggersOf(bar); triggers.forEach((t) => { if (!t.hasAttribute('role')) t.setAttribute('role', 'menuitem'); }); setRoving(bar, triggers[0]); // keep the state name honest whatever opened / closed a menu triggers.forEach((t) => { menuOf(t)?.addEventListener('toggle', () => { bar.dataset.stateName = openMenuOf(bar) ? 'open' : 'default'; }); }); // hover switching while a menu is open bar.addEventListener('pointerover', (e) => { const t = e.target instanceof Element ? e.target.closest('.menubar-trigger') : null; if (!t || t.closest('.menubar') !== bar || !triggersOf(bar).includes(t)) return; const open = openMenuOf(bar); if (open && menuOf(t) !== open) openMenu(bar, t, true); }); bar.addEventListener('keydown', (e) => { const ts = triggersOf(bar); const rtl = getComputedStyle(bar).direction === 'rtl'; const next = rtl ? 'ArrowLeft' : 'ArrowRight'; const prev = rtl ? 'ArrowRight' : 'ArrowLeft'; const onTrigger = e.target instanceof Element && e.target.classList.contains('menubar-trigger'); const openMenuEl = openMenuOf(bar); const i = onTrigger ? ts.indexOf(e.target as Element) : ts.findIndex((t) => menuOf(t) === openMenuEl); if (i < 0) return; const step = (d) => ts[(i + d + ts.length) % ts.length]; if (onTrigger) { // with a menu open (hover switching left focus on its trigger), the // arrows switch the open menu; otherwise they just move focus const moveTo = (t) => { if (openMenuEl) openMenu(bar, t); else { setRoving(bar, t); t.focus(); } }; switch (e.key) { case next: e.preventDefault(); moveTo(step(1)); break; case prev: e.preventDefault(); moveTo(step(-1)); break; case 'Home': e.preventDefault(); setRoving(bar, ts[0]); ts[0].focus(); break; case 'End': e.preventDefault(); setRoving(bar, ts[ts.length - 1]); ts[ts.length - 1].focus(); break; case 'ArrowDown': case 'Enter': case ' ': e.preventDefault(); openMenu(bar, ts[i]); break; case 'ArrowUp': { e.preventDefault(); const menu = menuOf(ts[i]); // up opens with the LAST item highlighted - in the open toggle, // after the dropdown's own handler highlighted the first one (the // toggle event is a queued task; a timer raced it under load) if (menu && !menu.matches(':popover-open')) { menu.addEventListener('toggle', (ev) => { if ((ev as ToggleEvent).newState === 'open') focusItem(menu, true); }, { once: true }); openMenu(bar, ts[i]); } else { openMenu(bar, ts[i]); if (menu) focusItem(menu, true); } break; } } return; } // inside an open menu: left / right that the dropdown did not use // (a submenu trigger opens on right, a submenu closes on left) move to // the neighbouring menu if (e.defaultPrevented) return; if (e.key === next) { e.preventDefault(); openMenu(bar, step(1)); } else if (e.key === prev) { e.preventDefault(); openMenu(bar, step(-1)); } }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub