Theme
Design your own
On this page (11)
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.

§API

Generated from menubar.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type MenubarState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
Every menu closed.
Config fieldTypeDescription
menu?string | nullreported by getState(): null - no menu is open
open
One menu open.
Config fieldTypeDescription
menu?string | number | nullthe menu to open: its id (its trigger's data-dropdown-trigger), or its index; default the first. getState() reports the open menu's id

Every element

MemberDescription
el.api.setState<S extends MenubarState>(name: S, config?: MenubarStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?MenubarStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: MenubarState; config: MenubarStateConfigs[MenubarState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.menubarApi.setState<S extends MenubarState>(el: HTMLElement, name: S, config?: MenubarStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?MenubarStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.menubarApi.getState(el: HTMLElement): { name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.menubarApi.render(state: { name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: MenubarState; config: MenubarStateConfigs[MenubarState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.menubarApi.store(el: HTMLElement): Store<{ name: MenubarState; config: MenubarStateConfigs[MenubarState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: MenubarState; config: MenubarStateConfigs[MenubarState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.menubarApi.commit<S extends MenubarState>(el: HTMLElement, name: S, config?: MenubarStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?MenubarStateConfigs[S]its config
df$.shadcn.menubarStates: MenubarState[]The declared states, 'default' first: default, open.

§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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
/** default = every menu closed; open = one menu open ({ menu: id | index }). */
const menubarStates = ['default', 'open'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state (getState() reports the open menu's id). */
export interface MenubarStateConfigs {
  /** Every menu closed. */
  default: {
    /** reported by getState(): null - no menu is open */
    menu?: string | null;
  };
  /** One menu open. */
  open: {
    /** the menu to open: its id (its trigger's data-dropdown-trigger), or its index; default the first. getState() reports the open menu's id */
    menu?: string | number | null;
  };
}
const triggersOf = (bar) => Array.from(dfDollar(bar).find<HTMLButtonElement>('.menubar-trigger').toArray()).filter((t) => t.closest('.menubar') === bar && !t.disabled && t.getAttribute('aria-disabled') !== 'true');
const menuOf = (trigger) => dfDollar('#' + CSS.escape(trigger.dataset.dropdownTrigger || trigger.getAttribute('popovertarget') || '')).get(0);
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(dfDollar(menu).find('[role^="menuitem"]').toArray()).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 });
}
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(_el, _stateName) {
  // 'open' shows one of the bar's dropdown menus - a top-layer popover, not
  // markup; the roving tabindex and the triggers' aria-expanded the runtime
  // keeps are runtime-owned (see the e2e)
}
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 = componentState({
  component: 'menubar',
  states: menubarStates,
  apply: (bar, state) => {
    bar.dataset.stateName = state.name;
    triggerStateChange(bar, state.name, state.config);
  },
  read: (bar, state) => {
    const open = openMenuOf(bar);
    return { name: open ? 'open' : 'default', config: { ...state.config, menu: open?.id ?? null } };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.menubarApi = menubarApi;
df$.menubarStates = menubarStates;
function init() {
  dfDollar('.menubar:not([data-init])').toArray().forEach((bar) => {
    bar.dataset.init = '';
    if (!bar.hasAttribute('role')) bar.setAttribute('role', 'menubar');
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(bar, menubarApi);
    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<HTMLButtonElement>('.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 HTMLButtonElement) : 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