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