HyMenu
Usage examples on this page are written for Lit / plain HTML (
<hy-menu>). The same component ships asHyMenuin@whitespaceux/harmony-react(native React 19), with typed wrappers for Angular, Solid, Svelte, and Vue. The API reference below applies to all frameworks.
Child components: <hy-menu-group>, <hy-menu-item>, <hy-menu-item-checkbox>, <hy-menu-item-radio> — used only inside this one.
A role="menu" panel composed on top of hy-popover. Owns keyboard navigation (Arrow, Home/End, typeahead), roving tabindex, and submenu coordination. Items are slotted in the default slot and may be grouped with hy-menu-group or separated with hy-divider.
Trigger patterns
By id — pass for="<id>" referencing the trigger. A click on the trigger toggles the menu; don't add a click handler of your own (it would toggle twice). A menu with open in markup shows once the trigger resolves.
Programmatic — call menu.show(anchor?) with an optional element. When an element is passed, the menu anchors to it (generating an id if the element doesn't already have one).
Composite triggers — hy-menu-button, hy-context-menu, and hy-menubar-item wrap hy-menu and handle trigger wiring themselves.
Submenus
An hy-menu placed directly inside an hy-menu-item is that row's submenu. It opens beside the row (right-start, left-start in right-to-left text, flipping when there is no room) unless it sets its own placement; hovering the row, a click, Enter, Space or ArrowRight opens it, and hovering another row, ArrowLeft, Escape or a click outside closes it. Touch has no hover: a tap opens it. Picking an item in a submenu closes the whole stack, down to the root menu (APG), and fires one select. The same holds for the rows of an hy-menu-button or an hy-context-menu: a row with an hy-menu inside opens it beside the row.
Name
A menu is named by the element that opens it — its trigger, or for a submenu its row — unless label is set, which wins.
Radio items
hy-menu-item-radio rows sharing a name exclude one another across the menu (groups narrow nothing), and the menu emits change for them.
When to use
- A list of actions opened from a control that is not a menu button: a card, a custom trigger, with
for. - Choosing one or several options that act at once: a sort order, columns to show.
Do
- Put destructive actions last, set apart from the others.
- Group a long menu with
hy-menu-group.
When not to use
- A value a form submits:
hy-select, orhy-comboboxwhen the list is long enough to filter. - Navigation people need all the time:
hy-sidebar,hy-tab-navor links in ahy-topbar. - A button that opens a menu:
hy-menu-button, which wires the trigger and returns focus. - Forms, images or long text in a floating panel:
hy-popover, orhy-dialogwhen it must block. - Commands for the thing under the pointer, opened on right-click:
hy-context-menu.
Accessibility: your part
- When you open the menu from your own control with
for, give that controlaria-haspopup="menu"and keep itsaria-expandedin step with the menu.
Examples
Menu attached to a trigger by id
<hy-button id="edit-trigger">Edit</hy-button>
<hy-menu for="edit-trigger">
<hy-menu-item value="cut" label="Cut" shortcut="⌘X"></hy-menu-item>
<hy-menu-item value="copy" label="Copy" shortcut="⌘C"></hy-menu-item>
<hy-menu-item value="paste" label="Paste" shortcut="⌘V"></hy-menu-item>
<hy-divider></hy-divider>
<hy-menu-item value="delete" label="Delete" variant="danger"></hy-menu-item>
</hy-menu>
<script>
document.querySelector('hy-menu').addEventListener('select', (e) => {
console.log(e.detail.value);
});
</script>Grouped items with checkbox and radio rows
<hy-button id="view-trigger">View</hy-button>
<hy-menu for="view-trigger">
<hy-menu-group label="Panels">
<hy-menu-item-checkbox value="word-wrap" label="Word wrap" checked></hy-menu-item-checkbox>
<hy-menu-item-checkbox value="minimap" label="Minimap"></hy-menu-item-checkbox>
</hy-menu-group>
<hy-divider></hy-divider>
<hy-menu-group label="Zoom" role="radiogroup">
<hy-menu-item-radio name="zoom" value="zoom-50" label="50%"></hy-menu-item-radio>
<hy-menu-item-radio name="zoom" value="zoom-100" label="100%" checked></hy-menu-item-radio>
<hy-menu-item-radio name="zoom" value="zoom-150" label="150%"></hy-menu-item-radio>
</hy-menu-group>
</hy-menu>API
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
open | open | boolean | false | Controlled open state. |
placement | placement | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end' | — | Placement relative to the anchor. Unset, a menu opens below its trigger (bottom-start) and a submenu (an hy-menu directly inside an hy-menu-item) opens beside its row — right-start, left-start in right-to-left text — flipping to the other side when there is no room. An explicit value wins in both cases. |
distance | distance | number | 4 | Gap between anchor and panel in pixels. |
loop | loop | boolean | true | Wrap focus at the first/last item. |
typeahead | typeahead | boolean | true | First-character typeahead jumps to matching item. |
for | for | string | '' | Anchor element id (forwarded to hy-popover). |
size | size | 'small' | 'medium' | 'medium' | Density variant applied to all descendant menu items that don't have their own size set. hy-menu-button forwards its own size here. |
width | width | string | '' | Fixed panel width (any CSS length, e.g. '220px'). Writes to --hy-menu-width. Leave empty for intrinsic sizing. |
minWidth | min-width | string | '' | Minimum panel width (any CSS length). Writes to --hy-menu-min-width. Leave empty for the default (12rem). |
label | label | string | '' | The menu's accessible name. Unset, the menu is named by the element that opens it — its trigger (the for element, the element passed to show(), a menu button's trigger, a menubar item) or, for a submenu, its row. Set it when the trigger has no useful name of its own. |
Events
| Event | Detail | Description |
|---|---|---|
select | — | { value, item, originalEvent } — bubbled up when any child item fires select. A pick in a submenu reaches the root once, from the submenu. |
change | — | { selectedValue, name, reason } — a named radio item was checked. |
show | — | { source } — forwarded from the underlying popover (does NOT bubble). |
hide | — | { source } — forwarded from the underlying popover (does NOT bubble). |
request-show | — | { source } — cancelable, forwarded from popover. |
request-hide | — | { source } — cancelable, forwarded from popover. |
Slots
| Slot | Description |
|---|---|
default | Menu items (hy-menu-item, checkbox/radio variants, groups, dividers). |
CSS Parts
| Part | Description |
|---|---|
panel | The popover panel surface (delegated to hy-popover::part(body)). |
CSS Custom Properties
The documented hooks. Every override hook the component reads — one per token it paints — is listed in @whitespaceux/harmony/theme-api.json, and @whitespaceux/harmony/component-bindings.json (the Harmony MCP get-bindings tool) says which part and state each one paints.
| Property | Default | Description |
|---|---|---|
--hy-menu-padding-layout-xs | var(--hy-padding-layout-xs) | — |
--hy-menu-padding-layout-none | var(--hy-padding-layout-none) | — |
--hy-menu-min-width | — | Minimum panel width (default: 12rem). Overridden by the min-width attribute. |
--hy-menu-width | auto | Panel width (default: auto). Overridden by the width attribute. |
--hy-menu-gap-row-list-xs-block | var(--hy-gap-row-list-xs-block) | Gap between two menu items, so a hovered item never touches a checked one (default: the row-list xs gap, the sidebar's 4 px at standard). |
Methods
show()
Open the menu. When anchor is passed, it becomes the popover's anchor element (an id is generated on the element if it doesn't have one).
focusItem()
Focus a menu item by its index in the collected items list.
getItems()
Return the currently collected items (light-DOM order, including grouped).