Skip to content

Menu

hc-menu styles a native popover as a WAI-ARIA action menu. The trigger is a normal button; the popovertarget attribute connects the two without any JS, and installMenu() adds the keyboard pattern, ARIA wiring, and a hc:menuselect event.

PrimitiveRequired version
HTML popoverChrome 114, Edge 114, Firefox 125, Safari 17
CSS Anchor PositioningChrome 125, Edge 125, Firefox 147, Safari 26

When Anchor Positioning is missing, installMenu() falls back to positioning the menu via getBoundingClientRect on the popover’s beforetoggle event. The menu still opens, dismisses, and behaves correctly everywhere popover is supported.

import { installMenu } from '@hypermedia-components/core';
installMenu();
  • Wires ARIA on the trigger: aria-haspopup="menu", aria-expanded (kept in sync via the popover toggle event), aria-controls.
  • Adds autofocus to the first enabled menu item so the browser’s popover algorithm focuses it on open — no JS race against the browser’s own focus management.
  • Injects matching anchor-name (on the trigger) and position-anchor (on the menu) so the menu lands directly under the trigger via CSS Anchor Positioning.
  • Implements the APG keyboard pattern:
    • ArrowDown / ArrowUp move focus through enabled items, wrapping at the edges.
    • Home / End jump to the first / last enabled item.
    • Single-letter keys jump to the next item starting with that letter (type-ahead).
    • Tab closes the menu so focus continues to the next tab-stop after the trigger.
    • Escape and outside click are handled natively by popover.
  • Skips items marked with disabled or aria-disabled="true".
  • On menuitem click, dispatches a bubbling hc:menuselect custom event whose detail carries { item, menu, trigger }, then closes the menu via hidePopover().

Two additional ARIA roles let menu items carry state instead of firing a one-shot action:

  • role="menuitemcheckbox" — independent on/off, multiple may be checked at once. Click toggles aria-checked.
  • role="menuitemradio" — mutually exclusive within a group. Click sets this item’s aria-checked="true" and every sibling’s to "false". The group boundary is the nearest [role="group"] ancestor, falling back to the menu itself.

Both keep the menu open on click (shadcn / Radix convention — users typically toggle multiple options without reopening). The hc:menuselect event still fires; its detail.checked carries the new boolean state.

The <span class="hc-menu__label"> element renders a small muted heading above a group; pair it with aria-labelledby on the surrounding <div role="group">.

document.getElementById('view-menu').addEventListener('hc:menuselect', (e) => {
const { item, checked } = e.detail;
console.log(item.textContent.trim(), '', checked);
// e.g. 'Sidebar → true'
});

When the menu contains any checkbox or radio item, every item in the menu gets a reserved indicator column on the left so plain menuitems align with the check / dot marker. The column is enabled purely via CSS :has() — no markup changes needed.

Mirroring shadcn’s destructive variant, data-variant="error" on a menu item recolors its text via --hc-menu-item-error-fg.

<button class="hc-menu__item" role="menuitem" type="button"
data-variant="error">
Delete
</button>

A menu item can open a submenu — a nested .hc-menu it controls. Nest the submenu inside the root menu’s DOM and point the parent item at it with data-hc-submenu="<submenu-id>". installMenu() adds popover="auto" and the submenu ARIA automatically; the same wiring powers hc-context-menu submenus for free.

<div class="hc-menu" id="edit-menu" popover role="menu"
aria-labelledby="edit-trigger">
<button class="hc-menu__item" role="menuitem" type="button">Undo</button>
<!-- parent: data-hc-submenu references the nested menu's id -->
<button class="hc-menu__item" role="menuitem" type="button"
data-hc-submenu="edit-more">More tools</button>
<div class="hc-menu" id="edit-more" role="menu" aria-label="More tools">
<button class="hc-menu__item" role="menuitem" type="button">Inspect</button>
<button class="hc-menu__item" role="menuitem" type="button">Save as…</button>
</div>
<button class="hc-menu__item" role="menuitem" type="button">Paste</button>
</div>

Nesting the submenu inside the root popover is what keeps the root open when the submenu shows (the HTML popover “nested” rule).

Interaction (WAI-ARIA APG submenu pattern):

ActionResult
Hover the parentOpens the submenu (focus stays put).
Click, Enter, Space, or Opens it and focuses the first item.
Closes the submenu, focus returns to the parent.
EscCloses the submenu (then the root).
Hovering a siblingCloses the open submenu.
Selecting any leafCloses the whole tree.

In RTL the open/close arrows mirror. The parent item shows a trailing chevron (::after). Placement uses CSS Anchor Positioning (submenu to the inline-end, flipping to the inline-start at the edge), with the same JS fallback as the dropdown for engines without it. aria-haspopup="menu", aria-expanded, and aria-controls are kept in sync on the parent.

A menu item can fire an htmx request via the hc:menuselect event. The behavior closes the menu after dispatching, so subsequent network work happens with focus restored to the trigger (popover’s default).

<div class="hc-menu" id="row-menu" popover role="menu"
aria-labelledby="row-menu-trigger"
data-hx-target="closest tr"
data-hx-trigger="hc:menuselect"
data-hx-include="this">
<button class="hc-menu__item" role="menuitem" type="button"
data-hx-delete="/items/42">Delete row</button>
</div>

For a richer pattern (a confirm dialog before the actual request), pair each menu item with hc-confirm-action.

  • Always associate the menu with its trigger using aria-labelledby pointing at the trigger’s id.
  • Every menu item needs role="menuitem". Use a real <button> (or <a> for navigation menus) so the item is reachable without tabindex.
  • Disable items by either the native disabled attribute or aria-disabled="true". Both are skipped by arrow navigation and ignored by hc:menuselect clicks.
  • Don’t override the focus outline. The active item receives the same focus background via :focus-visible.
  • menuitemcheckbox / menuitemradio items keep their own checked state via aria-checked (see Stateful items); the menu stays open after toggling one. Use the hc:menuselect event to persist the change. Submenus are supported via data-hc-submenu (see Submenus).

Component tokens (in component.tokens.json):

Token pathPurpose
menu.bg / fg / border / radiusMenu surface.
menu.padding-blockVertical padding around the item list.
menu.min-width / max-widthSurface constraints.
menu.offsetDistance between trigger and menu (Anchor Positioning).
menu.item.padding-x / padding-y / font-size / gapItem layout.
menu.item.fg / hover-bg / focus-bg / disabled-fgItem states.
menu.item.error-fgdata-variant="error" foreground.
menu.item.indicator-sizeSize of the check / dot column.
menu.label.padding-x / padding-y / font-size / font-weight / fg<span class="hc-menu__label"> group heading.
menu.separator.color / margin-y<hr class="hc-menu__separator">.
Show the generated CSS variables
--hc-menu-bg | -fg | -border | -radius | -padding-block
--hc-menu-min-width | -max-width | -offset
--hc-menu-item-padding-x | -padding-y | -font-size | -gap
--hc-menu-item-fg | -hover-bg | -focus-bg | -disabled-fg
--hc-menu-item-error-fg | -indicator-size
--hc-menu-label-padding-x | -padding-y | -font-size | -font-weight | -fg
--hc-menu-separator-color | -margin-y
--hc-color-focus-ring (inherited from data-color)
  • Popover — bare popover surface without the menu pattern.
  • Dialog — modal alternative for richer flows.
  • Toolbar — horizontal action cluster often paired with a menu trigger.