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/menubarRequires 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.
stems-ui/menubar
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.
Usage
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.
stems-ui/menubar
Context: owns the menubar state. Props (Base UIMenubar):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 totrue.modal: defaults totrue. 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 themodalprop 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, anddata-has-submenu-openwhile a menu is open. Name it witharia-labeloraria-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.
Behavior notes
- 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', whichlib/popup.tsdoesn't have). - Server rendering: the root renders before its menus, so a menu that starts open
(
defaultOpen) is reflected in the root'sdata-has-submenu-openonly after hydration. - Base UI's
Portalpart has no Stems equivalent: popups are shown in the top layer where they are rendered.
| Key | On a trigger (horizontal) | In an open menu (horizontal) |
|---|---|---|
ArrowRight / ArrowLeft | Next / previous trigger (mirrored in RTL) | Next / previous menu |
ArrowDown / ArrowUp | Open, focus the first / last item | Next / previous item |
Home / End | First / last trigger | First / last item |
Enter / Space | Open, focus the first item | Activate the item |
Escape | – | Close, focus the trigger |
Styling hooks
| Part | Attribute | Present when / value |
|---|---|---|
| root | data-orientation | "horizontal" or "vertical" |
| root | data-modal | modal is true (the default) |
| root | data-has-submenu-open | One of the menubar's menus is open |
| trigger | data-popup-open | Its menu is open |
| trigger | data-pressed | Its menu is open |
| trigger | data-disabled | The menu or the menubar is disabled |
The popups and items have the stems-ui/menu hooks (data-open / data-closed, data-side,
data-highlighted, ...).
stems-ui/menu
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.
Usage
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>
Differences from Base UI
- No Portal, no Positioner element. The popup is shown in the top layer with
popover="manual"; Base UI's Positioner props are options ofpopup()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'schecked/defaultChecked/onCheckedChange) +checkboxItem(),RadioGroupContext(RadioGroup'svalue/defaultValue/onValueChange) +radioGroup(),RadioItemContext(RadioItem'svalue) +radioItem(). The indicators read the nearest item provider. - Submenus must be rendered inside the parent's popup (in the DOM):
SubmenuContext= Base UISubmenuRoot. - Indicators always stay in the DOM (
hiddenwhile unchecked).keepMountedkeeps them visible instead. MenuSelectEvent(stems:menu-select) is a Stems extra (Remix hadonMenuSelect); items accept avalueoption reported by it.- Filtering (Base UI 1.9
Menu.FilterProvider/Input/Clear/Empty/List): see Filtering.Emptyis a live region that stays rendered (put the message inEmptyText), andCleargetshiddeninstead of unmounting.list()without aFilterProvideris a plainrole="presentation"wrapper (the popup keepsrole="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 beyondinert.
stems-ui/menu
Providers
Context(Base UIMenu.Root): owns the open state. Read it withhandle.context.get(menu.Context)(open,setOpen(open, { reason }),close(),highlighted,store, …).open/defaultOpen/onOpenChange(open, details):detailshasreason,event,trigger,cancel().onOpenChangeComplete(open): after the enter / exit animation.modal:true(default) |false|'trap-focus'.truelocks page scroll and makes outside contentinert. A menu opened by hovering its trigger is never modal.loopFocus:boolean, defaulttrue. Arrow keys wrap at the ends.orientation:'vertical'(default) |'horizontal'. Which arrows move between items.disabled: disables the trigger.highlightItemOnHover:boolean, defaulttrue.onItemHighlighted(item | undefined, { reason: 'keyboard' | 'pointer' | 'none', event, label }).
SubmenuContext(Base UIMenu.SubmenuRoot): same props exceptmodal(submenus are never modal), pluscloseParentOnEsc(defaultfalse: Escape closes only the submenu).CheckboxItemContext:checked/defaultChecked(defaultfalse) /onCheckedChange(checked, details)(details.cancel()vetoes),disabled.RadioGroupContext:value/defaultValue/onValueChange(value, details),disabled.RadioItemContext:value(required),disabled.
Mixins
trigger(options?):type="button",id,aria-haspopup="menu",aria-expanded,aria-controls. Options:disabled,openOnHover,delay(default100),closeDelay(default0).popup(options?):role="menu",aria-labelledby(the trigger),aria-orientationwhen horizontal,popover="manual",tabIndex={-1},hiddenwhile 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(defaulttrue),value.linkItem(options?): for an<a href>. Options:label,closeOnClick(defaultfalse).checkboxItem(options?):role="menuitemcheckbox",aria-checked. Options:label,closeOnClick(defaultfalse),value.checkboxItemIndicator(options?)/radioItemIndicator(options?):aria-hidden;hiddenonce unchecked (after its exit animation) unlesskeepMounted.radioGroup():role="group".radioItem(options?):role="menuitemradio",aria-checked. Options:label,closeOnClick(defaultfalse).group():role="group",aria-labelledbyitsgroupLabel()(set after hydration).groupLabel(): anidfor the enclosing group.separator(options?): the genericstems-ui/separatormixin.submenuTrigger(options?):role="menuitem",aria-haspopup="menu",aria-expanded,aria-controls. Options:label,disabled,openOnHover(defaulttrue),delay(default100),closeDelay(default0).
Events
MenuChangeEvent(stems:menu-change,onMenuChange(handler)): bubbles from the active trigger (a submenu's from its submenu trigger) withopenandreason.MenuSelectEvent(stems:menu-select,onMenuSelect(handler)): bubbles from an activated item, after checkbox / radio state changed, withitem(the element),kind('item'|'checkbox-item'|'radio-item'|'link-item'),label,value(the radio value; thehreffor links),checked, andcloseOnClick. 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'.
Detached triggers
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 theContext(handleprop, read once) and to each detachedtrigger({ handle, payload })(read on the trigger's first render).Payloadtypespayloadin theContext's children function.- Detached triggers register with the handle, so they behave like triggers inside the
Context:aria-expanded,aria-controls,data-popup-open, anddata-pressedfollow 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
payloadis{ payload }in a children render function (re-run each time theContextrenders) andpayloadon the context value (handle.context.get(menu.Context).payload). It'sundefineduntil a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElementrejects function children).payloadalso works on triggers inside theContext. - Handle methods:
isOpen,open(triggerId?)(opens, or moves an open popup, to the trigger with thatid; reason'imperative-action'),close(). They act on the mountedContextand are ignored while none is mounted. A remountedContextstarts fresh. ContextpropstriggerId(controlled, pair withopen; storedetails.trigger?.idfromonOpenChange) anddefaultTriggerIdpick the active trigger by elementid.- 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 withoutaria-controls; it getsaria-controlsonce theContextmounts in the browser. - Only top-level
menu.Contexttakes ahandle(not submenus, context menus, or menus inside amenubar.Context).
Filtering
<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 UIMenu.FilterProvider) wraps aContextor aSubmenuContext. 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.detailshasreason('input-change','input-clear','clear-press','popup-close'),event, andcancel().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'slabel(or text) and the trimmed query. The default keeps items that contain the query, ignoring case and diacritics (the samecontainsText()combobox uses, exported withstartsWithText()).nullhides nothing, for filtering the rendered items yourself.locale: for the default matching.
input()(Base UIMenu.Input):type="text",role="searchbox",autocomplete="off",aria-controls(the list),aria-activedescendant(the highlighted item; items get anidwhen first highlighted if they have none),data-highlightedwhile focused and no item is highlighted by the keyboard. Itsvaluefollows the query.clear(options?)(Base UIMenu.Clear):type="button",tabIndex={-1},aria-hidden,hiddenwhile the query is empty,data-disabled. A press clears the query (reason'clear-press') and refocuses the input;mousedownis prevented so focus stays in the input. Option:disabled.empty()(Base UIMenu.Empty):role="status",aria-live="polite",aria-atomic,data-emptywhile no item of this menu is shown.EmptyTextrenders its children only then. Neither shows anything in server HTML.list()(Base UIMenu.List):role="menu", anid(default<popup id>-list),tabIndex={-1},aria-labelledby(the trigger).onMenuFilterChange(handler):MenuFilterChangeEvent(stems:menu-filter-change, from the input, bubbles) withvalueandreason.
Behavior:
- With a
FilterProvider, the trigger hasaria-haspopup="dialog"and the popuprole="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 setdisplayon 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
autoHighlighthighlights 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.
Behavior notes
- 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'sclick()); 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
delaywithout 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 unlesscloseParentOnEsc. 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 unlesscloseOnClick. Focus returns to the trigger. - Highlight: items get real DOM focus.
data-highlightedfollows 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 isinert, so pressing the trigger is an outside press. - Closed popups and unchecked indicators get
hiddenin server HTML;aria-checked,data-checked/data-unchecked, anddata-disabledare correct before hydration.
Styling hooks
| Part | Attribute / variable | Values / notes |
|---|---|---|
| trigger | data-popup-open, data-pressed | present while open |
| trigger | data-disabled | present when disabled |
| popup | data-open / data-closed | open state |
| popup | data-starting-style / data-ending-style | enter / exit transition |
| popup, arrow | data-side | top bottom left right inline-start inline-end (final side) |
| popup, arrow | data-align | start center end |
| popup | --anchor-width, --anchor-height | trigger size |
| popup | --available-width, --available-height | space to the viewport edges |
| popup | --transform-origin | for scale animations |
| arrow | data-uncentered, data-open / data-closed | |
| backdrop | data-open / data-closed, presence attrs | |
| item, link, sub-trigger | data-highlighted | present while highlighted |
| item, checkbox, radio, sub-trigger | data-disabled | present when disabled |
| checkbox / radio item | data-checked / data-unchecked | checked state (also aria-checked) |
| indicators | data-checked / data-unchecked, data-disabled, presence attrs | |
| radio group | data-disabled | present when disabled |
| sub-trigger | data-popup-open | present while its submenu is open |
| separator | data-orientation | horizontal / vertical |
| filter input | data-highlighted | focused, and no item is highlighted by the keyboard |
| filter clear | data-disabled, hidden | hidden while the query is empty |
| filter empty | data-empty | present while no item matches |
| item, sub-trigger, group | hidden | set by the filter for non-matching items |