Theme
On this page (10)
Component Skill — components/menubar/component-skill.md

Native basis

A role="menubar" of trigger buttons, each opening a popover menu anchored to it (CSS anchor positioning); submenus are popovers nested in their menu, so the browser keeps the tree open and dismisses it together.

Web Platform APIs

popoveranchor positioningAPG menubar:dir()

Classes

.menubar.menubar-trigger.dropdown-content.dropdown-item.dropdown-sub.dropdown-sub-trigger.dropdown-sub-content.dropdown-check.dropdown-radio

Data attributes

On the bar: data-variant (muted, ghost), data-size (sm, lg); on each trigger: data-dropdown-trigger="menu-id"; in the menus: everything the dropdown supports (data-inset, data-variant="destructive", disabled, aria-checked).

§Menubar

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.

§Submenus - three levels deep

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 closed
  • open - 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:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseA 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