Menu#

The WAI-ARIA menu button: a trigger that opens a role="menu" of actions, with arrow-key navigation, typeahead and a select event that carries the chosen value. The popup is popover="auto", so the top layer, light dismiss and Escape come from the platform. The same anatomy serves checkable items, a right-click context menu and submenus nested to any depth.

Import#

TSX
import { Menu } from '@sigx/zero/menu';

Menu is a compound: Menu.Root, Menu.Trigger, Menu.ContextTrigger, Menu.Popup, Menu.Item, Menu.CheckboxItem, Menu.RadioGroup, Menu.RadioItem, Menu.Sub, Menu.SubTrigger, Menu.SubPopup, Menu.Group, Menu.GroupLabel, Menu.Separator. It is also re-exported from the @sigx/zero root, together with menuAnatomy, useMenuContext, useMenuSubContext and useMenuRadioGroupContext.

Usage#

TSX
import { component } from 'sigx';
import { Menu } from '@sigx/zero/menu';

const Actions = component(({ signal }) => {
    const state = signal({ open: false });

    return () => (
        <Menu.Root model={() => state.open} onSelect={(value) => act(value)}>
            <Menu.Trigger color="neutral">Actions</Menu.Trigger>
            <Menu.Popup>
                <Menu.Item value="rename">Rename</Menu.Item>
                <Menu.Item value="duplicate">Duplicate</Menu.Item>
                <Menu.Separator />
                <Menu.Item value="delete">Delete…</Menu.Item>
            </Menu.Popup>
        </Menu.Root>
    );
});

model={() => state.open} binds the open state both ways; leave it off and the menu manages it, with openChange reporting each change. Activating an item emits select with its value and, by default, closes the menu (closeOnSelect). Menu.Root renders no element of its own — it only provides context. On open, focus lands on the first enabled item; on close it returns to the trigger. See Models.

Checkbox and radio items#

TSX
<Menu.Popup>
    <Menu.CheckboxItem value="statusbar" model={() => state.statusbar}>Status bar</Menu.CheckboxItem>
    <Menu.CheckboxItem value="minimap" model={() => state.minimap}>Minimap</Menu.CheckboxItem>
    <Menu.Separator />
    <Menu.RadioGroup model={() => state.sortBy}>
        <Menu.GroupLabel>Sort by</Menu.GroupLabel>
        <Menu.RadioItem value="name">Name</Menu.RadioItem>
        <Menu.RadioItem value="date">Date</Menu.RadioItem>
    </Menu.RadioGroup>
</Menu.Popup>

Menu.CheckboxItem is an APG menuitemcheckbox with a per-item boolean model; Menu.RadioGroup holds one string model over its Menu.RadioItems (menuitemradio) and renders the same labelled group part Menu.Group does. Both carry data-state checked / unchecked and aria-checked, emit the root's select alongside their own model events, and — unlike plain items — leave the menu open by default so several options can be set in one visit; closeOnSelect on the item opts back into closing. Each auto-renders an item-indicator part mirroring its checked state; zero leaves it empty and the design system draws the mark. Under asChild only your element renders, so bring your own mark there.

Groups#

TSX
<Menu.Group>
    <Menu.GroupLabel>Export</Menu.GroupLabel>
    <Menu.Item value="pdf">PDF</Menu.Item>
    <Menu.Item value="csv">CSV</Menu.Item>
</Menu.Group>

A group is role="group", named by its GroupLabel while one is rendered. Labels never register as items, so arrow keys and typeahead walk straight through group boundaries.

Context menu#

TSX
<Menu.Root onSelect={(value) => act(value)}>
    <Menu.ContextTrigger>
        <div class="canvas">Right-click me</div>
    </Menu.ContextTrigger>
    <Menu.Popup>…</Menu.Popup>
</Menu.Root>

Menu.ContextTrigger wraps any surface. A right-click (or an Android long-press) opens the same popup at the pointer through a virtual anchor — deferred past the gesture, since an auto popover opened mid-gesture would be light-dismissed by its own pointerup. Shift+F10 or the ContextMenu key, from the surface or any focused descendant, opens it anchored to the surface's rect. A second right-click while open repositions in place; the last opener — Trigger or ContextTrigger — wins the anchor. A context-menu-only composition has no visible trigger, so the popup carries no aria-labelledby in that case. iOS fires no native contextmenu event: pair -webkit-touch-callout: none with your own long-press recognizer.

TSX
<Menu.Popup>
    <Menu.Item value="copy">Copy</Menu.Item>
    <Menu.Sub>
        <Menu.SubTrigger>Share</Menu.SubTrigger>
        <Menu.SubPopup>
            <Menu.Item value="email">Email</Menu.Item>
            <Menu.Item value="link">Copy link</Menu.Item>
        </Menu.SubPopup>
    </Menu.Sub>
</Menu.Popup>

Menu.Sub shadows the menu context for its subtree, so Item, Group and Separator work unchanged inside a SubPopup and select bubbles to the root. The sub-trigger is an item of the parent level for roving and typeahead; hover opens the submenu after openDelay without moving focus, leaving closes it after closeDelay, and roving to a different parent item closes it. The nested popover="auto" is a DOM descendant of the parent popup, so the platform provides the stacking: opening a child keeps ancestors open, Escape closes only the innermost, light dismiss closes the chain, and opening a sibling closes the other.

Anatomy#

PartElementStatesFlagsNotes
triggerbuttonopen | closeddisabled, focus-visible, pressed, press-animatingaria-haspopup="menu", aria-expanded, aria-controls. Carries the variant axes. asChild.
popupdivopen | closedpopover="auto", role="menu", data-placement (resolved), aria-labelledby → the trigger while one renders.
itemdivdisabled, highlighted, pressed, press-animatingrole="menuitem", tabIndex=-1, aria-disabled. Inside popup. asChild.
checkbox-itemdivchecked | uncheckeddisabled, highlighted, pressed, press-animatingrole="menuitemcheckbox", aria-checked. Inside popup. asChild.
radio-itemdivchecked | uncheckeddisabled, highlighted, pressed, press-animatingrole="menuitemradio", aria-checked. Inside popup. asChild.
item-indicatorspanchecked | uncheckedAuto-rendered inside a checkbox or radio item, aria-hidden; empty — the design system draws the mark.
sub-triggerdivopen | closeddisabled, highlighted, pressed, press-animatingrole="menuitem", aria-haspopup="menu", aria-expanded, aria-controls. Inside popup. asChild.
sub-popupdivopen | closedpopover="auto", role="menu", data-placement (resolved), aria-labelledby → its sub-trigger. Inside popup.
context-triggerdivopen | closeddisabled, focus-visiblearia-haspopup="menu", aria-controls; no aria-expanded (invalid on a generic). asChild.
groupdivrole="group", aria-labelledby while a label renders. Inside popup.
group-labeldivInside group.
separatordivrole="separator". Inside popup.

Menu's Root renders a fragment, so there is no root part. The trigger is the carrier part: the variant axes live on Menu.Trigger, and a design system's axis rules for the scope anchor there. The popup is a top-layer sibling of the trigger, so colour styles the trigger, never the popup.

Items are flags-only by contract — data-highlighted follows focus, and hover moves focus so the two never disagree. The stateful items and the sub-trigger are distinct parts precisely because they carry a data-state the plain item is without: a recipe keys the mark on [data-state="checked"] and keeps a sub-trigger visually active with [data-state="open"] after focus has moved into its submenu. sub-popup is its own part so a side-attached submenu can animate on its own axis. The context trigger tracks the surface's own focus — never a descendant's — and carries focus-visible so a design system has somewhere to hang a ring when the consumer makes the surface a tab stop; no shipped design system paints one. Trigger and every item publish press feedback. See The anatomy contract.

Props#

PropTypeDefaultDescription
modelbooleanTwo-way binding of the open state.
openChangeevent (open: boolean)Fires whenever the open state changes.
selectevent (value: string)Fires when an item is activated, from any depth.
closeOnSelectbooleantrueClose after a plain Item is activated; checkbox and radio items decide for themselves.
placementPlacement'bottom-start'Preferred side and alignment; the resolved value renders as data-placement on the popup.
offsetnumber4Gap between the anchor and the popup, in px.
positionStrategyPositionStrategyfixed-coordinatesReplaces the positioning implementation.
PropTypeDefaultDescription
disabledbooleanfalseRenders data-disabled.
color / size / variant / axes / modsdesign-system vocabularyThe variant axes for the scope, rendered on the trigger.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.
PropTypeDefaultDescription
disabledbooleanfalseRight-click and the keyboard shortcuts do nothing; renders data-disabled.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.

Only class.

PropTypeDefaultDescription
valuestringrequiredEmitted by select on activation.
textValuestringthe item's textWhat typeahead matches against.
disabledbooleanfalseSkipped by roving focus; renders data-disabled and aria-disabled.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.
PropTypeDefaultDescription
valuestringrequiredEmitted by select on activation.
modelbooleanTwo-way binding of the checked state.
defaultCheckedbooleanfalseInitial state when uncontrolled.
checkedChangeevent (checked: boolean)Fires whenever the checked state changes.
closeOnSelectbooleanfalseClose the menu when this item toggles.
textValuestringthe item's textWhat typeahead matches against.
disabledbooleanfalseRenders data-disabled and aria-disabled.
asChildbooleanfalseRender through the default slot; no item-indicator is rendered.
classstringExtra classes.
PropTypeDefaultDescription
modelstringTwo-way binding of the checked item's value.
defaultValuestring''Initial value when uncontrolled.
valueChangeevent (value: string)Fires whenever the selection changes.
classstringExtra classes on the group part.
PropTypeDefaultDescription
valuestringrequiredWritten to the group's model on activation; emitted by select.
closeOnSelectbooleanfalseClose the menu when this item is chosen.
textValuestringthe item's textWhat typeahead matches against.
disabledbooleanfalseRenders data-disabled and aria-disabled.
asChildbooleanfalseRender through the default slot; no item-indicator is rendered.
classstringExtra classes.
PropTypeDefaultDescription
modelbooleanTwo-way binding of the submenu's open state.
openChangeevent (open: boolean)Fires whenever the submenu opens or closes.
placementPlacement'right-start' ('left-start' under RTL)Preferred side and alignment of the sub-popup.
offsetnumber4Gap between the sub-trigger and the sub-popup, in px.
positionStrategyPositionStrategyfixed-coordinatesReplaces the positioning implementation.
openDelaynumber100Hover intent before opening, in ms.
closeDelaynumber300Delay before closing after the pointer leaves, in ms.
PropTypeDefaultDescription
valuestringthe sub's idIdentity in the parent list for roving and typeahead; never emitted by select.
textValuestringthe item's textWhat typeahead matches against.
disabledbooleanfalseRenders data-disabled and aria-disabled.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.

Keyboard#

KeyAction
Enter / Space / click on the triggerToggle the menu.
ArrowDown on a closed triggerOpen the menu.
ArrowDown / ArrowUpMove focus to the next / previous enabled item.
Home / EndFirst / last enabled item.
Printable charactersTypeahead on item text (or textValue).
Enter / SpaceActivate the focused item.
ArrowRight (ArrowLeft under RTL), Enter, Space on a sub-triggerOpen the submenu and focus its first item.
ArrowLeft (ArrowRight under RTL) inside a submenuClose it and return focus to the sub-trigger.
Shift+F10 / ContextMenu on a context-trigger surfaceOpen anchored to the surface.
EscapeClose the innermost open menu (native popover); focus returns to the opener.

In the shipped design systems#

@sigx/zero-basic and @sigx/zero-daisyui both wire color (the eight recommended roles) and size (xsxl) on the menu trigger, in each skin's own button idiom. Neither wires a variant or any mods on the scope. The popup, items, indicator, sub-trigger, sub-popup, group label and separator are styled by every shipped design system — the highlight on [data-highlighted], the mark on item-indicator[data-state="checked"] and the open state on the sub-trigger. The context trigger is the consumer's own content and ships unstyled.

Popover for an anchored surface of arbitrary content · Select when the list picks a form value rather than an action.