Stems UI
Menu

Menubar

A horizontal bar of menus, like a desktop application menu.

Last action: none · Profile: benoit

Codeapp/demos/public/menubar.tsx
import { clientEntry, type Handle } from 'remix/component'
import {
  Menubar,
  MenubarCheckboxItem,
  MenubarContent,
  MenubarGroup,
  MenubarItem,
  MenubarLabel,
  MenubarMenu,
  MenubarRadioGroup,
  MenubarRadioItem,
  MenubarSeparator,
  MenubarShortcut,
  MenubarSub,
  MenubarSubContent,
  MenubarSubTrigger,
  MenubarTrigger,
  onMenuSelect,
} from '@stemsui/registry/ui/menubar.tsx'

export const MenubarDemo = clientEntry(import.meta.url, function MenubarDemo(handle: Handle) {
  let lastAction = 'none'
  let bookmarksBar = false
  let fullUrls = true
  let profile = 'benoit'

  return () => (
    <div
      class="flex flex-col items-center gap-4"
      mix={onMenuSelect((event) => {
        if (event.kind === 'item') {
          lastAction = String(event.value ?? event.label)
          void handle.update()
        }
      })}
    >
      <Menubar aria-label="Application" class="w-72">
        <MenubarMenu>
          <MenubarTrigger>File</MenubarTrigger>
          <MenubarContent>
            <MenubarGroup>
              <MenubarItem value="new-tab">
                New Tab <MenubarShortcut>⌘T</MenubarShortcut>
              </MenubarItem>
              <MenubarItem value="new-window">
                New Window <MenubarShortcut>⌘N</MenubarShortcut>
              </MenubarItem>
              <MenubarItem disabled>New Incognito Window</MenubarItem>
            </MenubarGroup>
            <MenubarSeparator />
            <MenubarSub>
              <MenubarSubTrigger>Share</MenubarSubTrigger>
              <MenubarSubContent>
                <MenubarItem value="share-email">Email link</MenubarItem>
                <MenubarItem value="share-messages">Messages</MenubarItem>
                <MenubarItem value="share-notes">Notes</MenubarItem>
              </MenubarSubContent>
            </MenubarSub>
            <MenubarSeparator />
            <MenubarItem value="print">
              Print... <MenubarShortcut>⌘P</MenubarShortcut>
            </MenubarItem>
          </MenubarContent>
        </MenubarMenu>
        <MenubarMenu>
          <MenubarTrigger>Edit</MenubarTrigger>
          <MenubarContent>
            <MenubarItem value="undo">
              Undo <MenubarShortcut>⌘Z</MenubarShortcut>
            </MenubarItem>
            <MenubarItem value="redo">
              Redo <MenubarShortcut>⇧⌘Z</MenubarShortcut>
            </MenubarItem>
            <MenubarSeparator />
            <MenubarSub>
              <MenubarSubTrigger>Find</MenubarSubTrigger>
              <MenubarSubContent>
                <MenubarItem value="search-web">Search the web</MenubarItem>
                <MenubarSeparator />
                <MenubarItem value="find">Find...</MenubarItem>
                <MenubarItem value="find-next">Find Next</MenubarItem>
                <MenubarItem value="find-previous">Find Previous</MenubarItem>
              </MenubarSubContent>
            </MenubarSub>
            <MenubarSeparator />
            <MenubarItem value="cut">Cut</MenubarItem>
            <MenubarItem value="copy">Copy</MenubarItem>
            <MenubarItem value="paste">Paste</MenubarItem>
          </MenubarContent>
        </MenubarMenu>
        <MenubarMenu>
          <MenubarTrigger>View</MenubarTrigger>
          <MenubarContent class="w-64">
            <MenubarCheckboxItem
              checked={bookmarksBar}
              onCheckedChange={(checked) => {
                bookmarksBar = checked
                void handle.update()
              }}
            >
              Always Show Bookmarks Bar
            </MenubarCheckboxItem>
            <MenubarCheckboxItem
              checked={fullUrls}
              onCheckedChange={(checked) => {
                fullUrls = checked
                void handle.update()
              }}
            >
              Always Show Full URLs
            </MenubarCheckboxItem>
            <MenubarSeparator />
            <MenubarItem inset value="reload">
              Reload <MenubarShortcut>⌘R</MenubarShortcut>
            </MenubarItem>
            <MenubarItem inset disabled>
              Force Reload <MenubarShortcut>⇧⌘R</MenubarShortcut>
            </MenubarItem>
            <MenubarSeparator />
            <MenubarItem inset value="fullscreen">
              Toggle Fullscreen
            </MenubarItem>
          </MenubarContent>
        </MenubarMenu>
        <MenubarMenu>
          <MenubarTrigger>Profiles</MenubarTrigger>
          <MenubarContent>
            <MenubarGroup>
              <MenubarLabel inset>Switch profile</MenubarLabel>
              <MenubarRadioGroup
                value={profile}
                onValueChange={(value) => {
                  profile = String(value)
                  void handle.update()
                }}
              >
                <MenubarRadioItem value="andy">Andy</MenubarRadioItem>
                <MenubarRadioItem value="benoit">Benoit</MenubarRadioItem>
                <MenubarRadioItem value="luis">Luis</MenubarRadioItem>
              </MenubarRadioGroup>
            </MenubarGroup>
            <MenubarSeparator />
            <MenubarItem inset value="edit-profiles">
              Edit...
            </MenubarItem>
            <MenubarSeparator />
            <MenubarItem inset value="add-profile">
              Add Profile...
            </MenubarItem>
          </MenubarContent>
        </MenubarMenu>
      </Menubar>
      <p class="text-sm text-muted-foreground">
        Last action: {lastAction} · Profile: {profile}
      </p>
    </div>
  )
})

Installation

npx shadcn add @stemsui/menubar

Requires the one-time setup. Behavior: stems-ui/menubar, stems-ui/menu.

API reference

The styled component is built on the headless modules below: its props mirror these options, and the same events and data attributes apply.

menubar turns a row (or column) of stems-ui/menu menus into an application menu bar: the triggers become role="menuitem" items with one roving tab stop, the arrow keys move between them and between open menus, and hovering another trigger while a menu is open switches to it. App code owns the markup and visual styles.

Base UI equivalent: Menubar.

import * as menu from 'stems-ui/menu'
import * as menubar from 'stems-ui/menubar'

function AppMenu() {
  return () => (
    <menubar.Context>
      <div aria-label="Application" mix={menubar.root()}>
        <menu.Context>
          <button mix={menu.trigger()}>File</button>
          <div mix={menu.popup({ sideOffset: 4 })}>
            <div mix={menu.item()}>New tab</div>
            <menu.SubmenuContext>
              <div mix={menu.submenuTrigger()}>Share</div>
              <div mix={menu.popup()}>
                <div mix={menu.item()}>Email link</div>
              </div>
            </menu.SubmenuContext>
          </div>
        </menu.Context>
        <menu.Context>
          <button mix={menu.trigger()}>Edit</button>
          <div mix={menu.popup({ sideOffset: 4 })}>
            <div mix={menu.item()}>Undo</div>
          </div>
        </menu.Context>
      </div>
    </menubar.Context>
  )
}

Every menu.Context rendered inside menubar.Context (not inside another menu) joins the menubar. Everything else about the menus (items, checkbox and radio items, groups, submenus, events, controlled open) works as in stems-ui/menu.

  • Context: owns the menubar state. Props (Base UI Menubar):
    • orientation: 'horizontal' (default; Left/Right between triggers, mirrored in RTL) or 'vertical' (Up/Down; menus open to the inline-end side).
    • loopFocus: wrap arrow-key focus at the ends. Defaults to true.
    • modal: defaults to true. While a menu is open, page scroll is locked and content outside the menubar is inert; the menubar stays interactive so other triggers can be hovered and pressed. It replaces the modal prop of the menus inside (which is ignored).
    • disabled: disable every menu.
    • handle.context.get(menubar.Context) returns { orientation, loopFocus, modal, disabled, hasSubmenuOpen }.
  • root(): role="menubar", aria-orientation, data-orientation, data-modal, and data-has-submenu-open while a menu is open. Name it with aria-label or aria-labelledby. Can be called without arguments.

What changes for a menu.trigger() in a menubar: it gets role="menuitem" and a roving tabIndex, the menubar's disabled applies, and its popup defaults to side: 'bottom', align: 'start' (side: 'inline-end' in a vertical menubar). The trigger keeps its aria-haspopup, aria-expanded, aria-controls, data-popup-open, and data-pressed.

  • Focus: the triggers form one tab stop: the trigger of the open menu, else the last focused trigger, else the first enabled one. Disabled triggers are skipped.
  • Pointer: pressing a trigger toggles its menu. While a menu is open, hovering another trigger focuses it and opens its menu (the hovered menu then stays open when the pointer leaves it, like a native menubar). Pressing outside closes the menu.
  • Keyboard on a trigger: Left / Right (horizontal) or Up / Down (vertical) move between the triggers, Home / End go to the first / last. When a menu is open, moving to another trigger opens that trigger's menu (focus stays on the trigger). ArrowDown / ArrowUp (horizontal) or the inline-end arrow (vertical) open the menu and focus its first / last item; Enter and Space open it with the first item focused.
  • Keyboard in an open menu (horizontal menubar): Left / Right open the previous / next menu and focus its first item, unless a submenu uses the key (Right on a submenu trigger opens the submenu, Left in a submenu closes it; Right on an item of a submenu moves to the next menubar menu, as in the WAI-ARIA menubar pattern). In a vertical menubar, the inline-start arrow closes the menu. Escape closes the menu and returns focus to its trigger; Tab closes it and moves on.
  • Change reasons: a menu opened by switching reports 'list-navigation' (arrow keys in a menu), 'trigger-focus' (arrow keys on a trigger), or 'trigger-hover'; the menu it replaces closes with 'none' (Base UI reports 'sibling-open', which lib/popup.ts doesn't have).
  • Server rendering: the root renders before its menus, so a menu that starts open (defaultOpen) is reflected in the root's data-has-submenu-open only after hydration.
  • Base UI's Portal part has no Stems equivalent: popups are shown in the top layer where they are rendered.
KeyOn a trigger (horizontal)In an open menu (horizontal)
ArrowRight / ArrowLeftNext / previous trigger (mirrored in RTL)Next / previous menu
ArrowDown / ArrowUpOpen, focus the first / last itemNext / previous item
Home / EndFirst / last triggerFirst / last item
Enter / SpaceOpen, focus the first itemActivate the item
Escape–Close, focus the trigger
PartAttributePresent when / value
rootdata-orientation"horizontal" or "vertical"
rootdata-modalmodal is true (the default)
rootdata-has-submenu-openOne of the menubar's menus is open
triggerdata-popup-openIts menu is open
triggerdata-pressedIts menu is open
triggerdata-disabledThe menu or the menubar is disabled

The popups and items have the stems-ui/menu hooks (data-open / data-closed, data-side, data-highlighted, ...).

menu wires a trigger button to a popup list of actions: menu roles, keyboard navigation with typeahead, highlight on hover, checkbox and radio items, submenus, and the popover machinery (top layer, positioning, dismissal, focus, enter/exit animation hooks). App code owns the markup and visual styles.

Base UI equivalent: Menu. Context menus are stems-ui/context-menu, which shares every part below.

import * as menu from 'stems-ui/menu'

let itemClass =
  'flex px-3 py-1.5 outline-none data-highlighted:bg-gray-100 data-disabled:opacity-50'

function ViewMenu(handle: Handle) {
  let sort = 'date'
  return () => (
    <menu.Context>
      <button mix={menu.trigger()}>View</button>
      <div
        class="min-w-40 rounded-md bg-white p-1 shadow-md data-starting-style:opacity-0 data-ending-style:opacity-0 transition-opacity"
        mix={menu.popup({ align: 'start', sideOffset: 4 })}
      >
        <div class={itemClass} mix={menu.item({ value: 'reload' })}>
          Reload
        </div>
        <div class="my-1 h-px bg-gray-200" mix={menu.separator()} />

        <menu.CheckboxItemContext defaultChecked>
          <div class={itemClass} mix={menu.checkboxItem()}>
            <span mix={menu.checkboxItemIndicator()}>✓</span> Word wrap
          </div>
        </menu.CheckboxItemContext>

        <menu.RadioGroupContext
          value={sort}
          onValueChange={(value) => {
            sort = String(value)
            void handle.update()
          }}
        >
          <div mix={menu.radioGroup()}>
            <div mix={menu.groupLabel()}>Sort by</div>
            <menu.RadioItemContext value="date">
              <div class={itemClass} mix={menu.radioItem()}>
                <span mix={menu.radioItemIndicator()}>•</span> Date
              </div>
            </menu.RadioItemContext>
            <menu.RadioItemContext value="name">
              <div class={itemClass} mix={menu.radioItem()}>
                <span mix={menu.radioItemIndicator()}>•</span> Name
              </div>
            </menu.RadioItemContext>
          </div>
        </menu.RadioGroupContext>

        <menu.SubmenuContext>
          <div class={itemClass} mix={menu.submenuTrigger()}>
            Zoom ›
          </div>
          <div class="rounded-md bg-white p-1 shadow-md" mix={menu.popup()}>
            <div class={itemClass} mix={menu.item({ value: 'zoom-in' })}>
              Zoom in
            </div>
            <div class={itemClass} mix={menu.item({ value: 'zoom-out' })}>
              Zoom out
            </div>
          </div>
        </menu.SubmenuContext>

        <a class={itemClass} href="/settings" mix={menu.linkItem()}>
          Settings
        </a>
      </div>
    </menu.Context>
  )
}

Listen for activations anywhere above the items:

<div mix={menu.onMenuSelect((event) => console.log(event.kind, event.value, event.checked))}>…</div>
  • No Portal, no Positioner element. The popup is shown in the top layer with popover="manual"; Base UI's Positioner props are options of popup() and the positioner's data attributes / CSS variables are set on the popup.
  • Item state lives in providers. A mixin can't provide context to its children, so the stateful items are split like accordion.ItemContext: CheckboxItemContext (Base UI CheckboxItem's checked / defaultChecked / onCheckedChange) + checkboxItem(), RadioGroupContext (RadioGroup's value / defaultValue / onValueChange) + radioGroup(), RadioItemContext (RadioItem's value) + radioItem(). The indicators read the nearest item provider.
  • Submenus must be rendered inside the parent's popup (in the DOM): SubmenuContext = Base UI SubmenuRoot.
  • Indicators always stay in the DOM (hidden while unchecked). keepMounted keeps them visible instead.
  • MenuSelectEvent (stems:menu-select) is a Stems extra (Remix had onMenuSelect); items accept a value option reported by it.
  • Filtering (Base UI 1.9 Menu.FilterProvider / Input / Clear / Empty / List): see Filtering. Empty is a live region that stays rendered (put the message in EmptyText), and Clear gets hidden instead of unmounting. list() without a FilterProvider is a plain role="presentation" wrapper (the popup keeps role="menu").
  • Not supported: the Viewport part, actionsRef, data-instant, --positioner-width/height, the 'sibling-open' / 'cancel-open' reasons (a sibling opening reports 'none'), opening on pointer down with press-drag-release selection, and outside-pointer blocking beyond inert.
  • Context (Base UI Menu.Root): owns the open state. Read it with handle.context.get(menu.Context) (open, setOpen(open, { reason }), close(), highlighted, store, …).
    • open / defaultOpen / onOpenChange(open, details): details has reason, event, trigger, cancel().
    • onOpenChangeComplete(open): after the enter / exit animation.
    • modal: true (default) | false | 'trap-focus'. true locks page scroll and makes outside content inert. A menu opened by hovering its trigger is never modal.
    • loopFocus: boolean, default true. Arrow keys wrap at the ends.
    • orientation: 'vertical' (default) | 'horizontal'. Which arrows move between items.
    • disabled: disables the trigger.
    • highlightItemOnHover: boolean, default true.
    • onItemHighlighted(item | undefined, { reason: 'keyboard' | 'pointer' | 'none', event, label }).
  • SubmenuContext (Base UI Menu.SubmenuRoot): same props except modal (submenus are never modal), plus closeParentOnEsc (default false: Escape closes only the submenu).
  • CheckboxItemContext: checked / defaultChecked (default false) / onCheckedChange(checked, details) (details.cancel() vetoes), disabled.
  • RadioGroupContext: value / defaultValue / onValueChange(value, details), disabled.
  • RadioItemContext: value (required), disabled.
  • trigger(options?): type="button", id, aria-haspopup="menu", aria-expanded, aria-controls. Options: disabled, openOnHover, delay (default 100), closeDelay (default 0).
  • popup(options?): role="menu", aria-labelledby (the trigger), aria-orientation when horizontal, popover="manual", tabIndex={-1}, hidden while closed. Options: side (default 'bottom'; submenus 'inline-end'), align (default 'center'; submenus 'start'), sideOffset, alignOffset, collisionPadding, arrowPadding, anchor, initialFocus, finalFocus (see popover).
  • arrow(), backdrop(): as in popover.
  • item(options?): role="menuitem", tabIndex={-1}. Options: label (typeahead text, defaults to the text content), disabled, closeOnClick (default true), value.
  • linkItem(options?): for an <a href>. Options: label, closeOnClick (default false).
  • checkboxItem(options?): role="menuitemcheckbox", aria-checked. Options: label, closeOnClick (default false), value.
  • checkboxItemIndicator(options?) / radioItemIndicator(options?): aria-hidden; hidden once unchecked (after its exit animation) unless keepMounted.
  • radioGroup(): role="group".
  • radioItem(options?): role="menuitemradio", aria-checked. Options: label, closeOnClick (default false).
  • group(): role="group", aria-labelledby its groupLabel() (set after hydration).
  • groupLabel(): an id for the enclosing group.
  • separator(options?): the generic stems-ui/separator mixin.
  • submenuTrigger(options?): role="menuitem", aria-haspopup="menu", aria-expanded, aria-controls. Options: label, disabled, openOnHover (default true), delay (default 100), closeDelay (default 0).
  • MenuChangeEvent (stems:menu-change, onMenuChange(handler)): bubbles from the active trigger (a submenu's from its submenu trigger) with open and reason.
  • MenuSelectEvent (stems:menu-select, onMenuSelect(handler)): bubbles from an activated item, after checkbox / radio state changed, with item (the element), kind ('item' | 'checkbox-item' | 'radio-item' | 'link-item'), label, value (the radio value; the href for links), checked, and closeOnClick. It is cancelable: preventDefault() keeps the menu open.

Change reasons: 'trigger-press', 'trigger-hover', 'outside-press', 'escape-key', 'item-press', 'list-navigation', 'focus-out', 'imperative-action', 'none'.

Base UI's createHandle(): triggers can live anywhere in the document, outside the Context, linked by a handle. Each trigger can pass a payload, so one popup can serve many triggers with different data.

import * as menu from 'stems-ui/menu'

let people = menu.createHandle<{ name: string }>()

// Anywhere, e.g. in a table row:
<button mix={menu.trigger({ handle: people, payload: { name: 'Ada' } })}>Ada</button>
<button mix={menu.trigger({ handle: people, payload: { name: 'Grace' } })}>Grace</button>

// Elsewhere:
<menu.Context handle={people}>
  {({ payload }) => <div mix={menu.popup()}>{payload?.name}</div>}
</menu.Context>
  • createHandle<Payload>() returns the handle. Pass it to the Context (handle prop, read once) and to each detached trigger({ handle, payload }) (read on the trigger's first render). Payload types payload in the Context's children function.
  • Detached triggers register with the handle, so they behave like triggers inside the Context: aria-expanded, aria-controls, data-popup-open, and data-pressed follow the menu, the popup is labelled by the active trigger, ArrowDown / ArrowUp on a trigger open the menu, the popup anchors to the active trigger, focus returns to it, and a press on another trigger counts as inside (it moves the popup instead of dismissing it).
  • The active trigger's payload is { payload } in a children render function (re-run each time the Context renders) and payload on the context value (handle.context.get(menu.Context).payload). It's undefined until a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElement rejects function children). payload also works on triggers inside the Context.
  • Handle methods: isOpen, open(triggerId?) (opens, or moves an open popup, to the trigger with that id; reason 'imperative-action'), close(). They act on the mounted Context and are ignored while none is mounted. A remounted Context starts fresh.
  • Context props triggerId (controlled, pair with open; store details.trigger?.id from onOpenChange) and defaultTriggerId pick the active trigger by element id.
  • Detached triggers aren't re-rendered by the Context; they update their state attributes directly when it renders. On the server nothing is shared (a handle is usually a module-level singleton), so a detached trigger renders closed and without aria-controls; it gets aria-controls once the Context mounts in the browser.
  • Only top-level menu.Context takes a handle (not submenus, context menus, or menus inside a menubar.Context).
<menu.FilterProvider autoHighlight>
  <menu.Context>
    <button mix={menu.trigger()}>Move to…</button>
    <div mix={menu.popup()}>
      <input aria-label="Filter folders" mix={menu.input()} />
      <button mix={menu.clear()}>Clear</button>
      <div mix={menu.empty()}>
        <menu.EmptyText>No folders found.</menu.EmptyText>
      </div>
      <div mix={menu.list()}>
        <div mix={menu.item()}>Inbox</div>
        <div mix={menu.item({ label: 'Résumés' })}>CVs</div>
      </div>
    </div>
  </menu.Context>
</menu.FilterProvider>
  • FilterProvider (Base UI Menu.FilterProvider) wraps a Context or a SubmenuContext. The first menu inside it owns it, so a submenu stays unfiltered unless it has its own provider. Props:
    • value / defaultValue (default '') / onValueChange(value, details): the query. details has reason ('input-change', 'input-clear', 'clear-press', 'popup-close'), event, and cancel().
    • autoHighlight: false (default), true (highlight the first match while the query isn't empty), or 'always' (also when it is empty).
    • filter(text, query): replaces the matching. It gets each item's label (or text) and the trimmed query. The default keeps items that contain the query, ignoring case and diacritics (the same containsText() combobox uses, exported with startsWithText()). null hides nothing, for filtering the rendered items yourself.
    • locale: for the default matching.
  • input() (Base UI Menu.Input): type="text", role="searchbox", autocomplete="off", aria-controls (the list), aria-activedescendant (the highlighted item; items get an id when first highlighted if they have none), data-highlighted while focused and no item is highlighted by the keyboard. Its value follows the query.
  • clear(options?) (Base UI Menu.Clear): type="button", tabIndex={-1}, aria-hidden, hidden while the query is empty, data-disabled. A press clears the query (reason 'clear-press') and refocuses the input; mousedown is prevented so focus stays in the input. Option: disabled.
  • empty() (Base UI Menu.Empty): role="status", aria-live="polite", aria-atomic, data-empty while no item of this menu is shown. EmptyText renders its children only then. Neither shows anything in server HTML.
  • list() (Base UI Menu.List): role="menu", an id (default <popup id>-list), tabIndex={-1}, aria-labelledby (the trigger).
  • onMenuFilterChange(handler): MenuFilterChangeEvent (stems:menu-filter-change, from the input, bubbles) with value and reason.

Behavior:

  • With a FilterProvider, the trigger has aria-haspopup="dialog" and the popup role="dialog" (it holds the searchbox and the menu list), as in Base UI.
  • Items, submenu triggers, and groups whose items all fail to match get hidden; they stay mounted. If your styles set display on a group or item, add a [hidden] rule.
  • Opening with a click or the keyboard focuses the input; a keyboard open also highlights the first item. Opening on hover, touch, or pen focuses the popup instead, so no on-screen keyboard shows up.
  • Focus stays in the input: ArrowDown / ArrowUp move the highlight (virtual focus, wrapping with loopFocus); Enter activates the highlighted item. Home / End jump to the ends only while the input is empty and an item is highlighted, and ArrowLeft / ArrowRight move the caret unless the input is empty or the highlighted item opens a submenu (then they open / close it). Characters and modified keys edit the text: typeahead is off. Tab / Shift+Tab close the menu as usual.
  • Pointer highlights don't move focus out of the input.
  • After the query changes, a highlighted item that no longer matches loses the highlight, and autoHighlight highlights the first match.
  • The query resets to '' (reason 'popup-close') once the menu has finished closing. Cancel that change to keep it.
  • Labels come from the rendered text, so server HTML shows every item even with a defaultValue; items are hidden once they mount in the browser.
  • Opening: a pointer press on the trigger focuses the popup with nothing highlighted; Enter / Space / ArrowDown focus the first enabled item, ArrowUp the last.
  • Keyboard in the popup: ArrowDown / ArrowUp (ArrowRight / ArrowLeft when horizontal, mirrored in RTL) move between enabled items, wrapping with loopFocus; Home / End; printable keys typeahead (repeat a letter to cycle); Enter / Space activate (they call the item's click()); Escape closes the topmost menu; Tab closes the whole tree and moves focus past the trigger (Shift+Tab to the trigger).
  • Submenus: ArrowRight (ArrowLeft in RTL), Enter, or Space on a submenu trigger opens the submenu and focuses its first item; ArrowLeft (ArrowRight in RTL) closes it and refocuses the trigger. Hovering opens it after delay without moving focus; the pointer can cross sibling items on its way to the submenu (hover aim). A press on the trigger opens it (and keeps a hover-opened submenu open). Highlighting another parent item closes it. Escape closes only the submenu unless closeParentOnEsc. Opening a submenu closes its open siblings; closing a menu closes its submenus.
  • Activation: item() closes the whole tree by default (reason 'item-press'); checkbox, radio and link items stay open unless closeOnClick. Focus returns to the trigger.
  • Highlight: items get real DOM focus. data-highlighted follows focus and pointer moves (not touch). When the pointer leaves an item for a non-item, the highlight clears and focus moves to the popup (a keyboard highlight is kept).
  • Dismissal: shared dismiss-layer stack. Pressing inside the parent closes an open submenu; pressing outside closes the tree. With modal (the default) everything outside the popup is inert, so pressing the trigger is an outside press.
  • Closed popups and unchecked indicators get hidden in server HTML; aria-checked, data-checked / data-unchecked, and data-disabled are correct before hydration.
PartAttribute / variableValues / notes
triggerdata-popup-open, data-pressedpresent while open
triggerdata-disabledpresent when disabled
popupdata-open / data-closedopen state
popupdata-starting-style / data-ending-styleenter / exit transition
popup, arrowdata-sidetop bottom left right inline-start inline-end (final side)
popup, arrowdata-alignstart center end
popup--anchor-width, --anchor-heighttrigger size
popup--available-width, --available-heightspace to the viewport edges
popup--transform-originfor scale animations
arrowdata-uncentered, data-open / data-closed
backdropdata-open / data-closed, presence attrs
item, link, sub-triggerdata-highlightedpresent while highlighted
item, checkbox, radio, sub-triggerdata-disabledpresent when disabled
checkbox / radio itemdata-checked / data-uncheckedchecked state (also aria-checked)
indicatorsdata-checked / data-unchecked, data-disabled, presence attrs
radio groupdata-disabledpresent when disabled
sub-triggerdata-popup-openpresent while its submenu is open
separatordata-orientationhorizontal / vertical
filter inputdata-highlightedfocused, and no item is highlighted by the keyboard
filter cleardata-disabled, hiddenhidden while the query is empty
filter emptydata-emptypresent while no item matches
item, sub-trigger, grouphiddenset by the filter for non-matching items