Skip to content

HyMenu ​

Usage examples on this page are written for Lit / plain HTML (<hy-menu>). The same component ships as HyMenu in @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.

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, or hy-combobox when the list is long enough to filter.
  • Navigation people need all the time: hy-sidebar, hy-tab-nav or links in a hy-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, or hy-dialog when 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 control aria-haspopup="menu" and keep its aria-expanded in step with the menu.

Examples ​

html
<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 ​

html
<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 ​

PropertyAttributeTypeDefaultDescription
openopenbooleanfalseControlled open state.
placementplacement'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.
distancedistancenumber4Gap between anchor and panel in pixels.
looploopbooleantrueWrap focus at the first/last item.
typeaheadtypeaheadbooleantrueFirst-character typeahead jumps to matching item.
forforstring''Anchor element id (forwarded to hy-popover).
sizesize'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.
widthwidthstring''Fixed panel width (any CSS length, e.g. '220px'). Writes to --hy-menu-width. Leave empty for intrinsic sizing.
minWidthmin-widthstring''Minimum panel width (any CSS length). Writes to --hy-menu-min-width. Leave empty for the default (12rem).
labellabelstring''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 ​

EventDetailDescription
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 ​

SlotDescription
defaultMenu items (hy-menu-item, checkbox/radio variants, groups, dividers).

CSS Parts ​

PartDescription
panelThe 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.

PropertyDefaultDescription
--hy-menu-padding-layout-xsvar(--hy-padding-layout-xs)—
--hy-menu-padding-layout-nonevar(--hy-padding-layout-none)—
--hy-menu-min-width—Minimum panel width (default: 12rem). Overridden by the min-width attribute.
--hy-menu-widthautoPanel width (default: auto). Overridden by the width attribute.
--hy-menu-gap-row-list-xs-blockvar(--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).

Built with Lit. Documented with VitePress.