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.
Browser baseline
Section titled “Browser baseline”| Primitive | Required version |
|---|---|
HTML popover | Chrome 114, Edge 114, Firefox 125, Safari 17 |
| CSS Anchor Positioning | Chrome 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.
Basic example
Section titled “Basic example”<button class="hc-button" type="button" popovertarget="account-menu" id="account-trigger"> Account</button>
<div class="hc-menu" id="account-menu" popover role="menu" aria-labelledby="account-trigger"> <button class="hc-menu__item" role="menuitem" type="button">Profile</button> <button class="hc-menu__item" role="menuitem" type="button">Billing</button> <button class="hc-menu__item" role="menuitem" type="button" aria-disabled="true">Archived</button> <hr class="hc-menu__separator"> <button class="hc-menu__item" role="menuitem" type="button" data-variant="error">Sign out</button></div>import { installMenu } from '@hypermedia-components/core';installMenu();What installMenu() does
Section titled “What installMenu() does”- Wires ARIA on the trigger:
aria-haspopup="menu",aria-expanded(kept in sync via the popovertoggleevent),aria-controls. - Adds
autofocusto 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) andposition-anchor(on the menu) so the menu lands directly under the trigger via CSS Anchor Positioning. - Implements the APG keyboard pattern:
ArrowDown/ArrowUpmove focus through enabled items, wrapping at the edges.Home/Endjump to the first / last enabled item.- Single-letter keys jump to the next item starting with that letter (type-ahead).
Tabcloses the menu so focus continues to the next tab-stop after the trigger.Escapeand outside click are handled natively bypopover.
- Skips items marked with
disabledoraria-disabled="true". - On menuitem click, dispatches a bubbling
hc:menuselectcustom event whosedetailcarries{ item, menu, trigger }, then closes the menu viahidePopover().
Stateful items (checkbox + radio)
Section titled “Stateful items (checkbox + radio)”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 togglesaria-checked.role="menuitemradio"— mutually exclusive within a group. Click sets this item’saria-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">.
<div class="hc-menu" id="view-menu" popover role="menu" aria-labelledby="view-menu-trigger"> <button class="hc-menu__item" role="menuitem" type="button">Refresh</button>
<hr class="hc-menu__separator">
<div role="group" aria-labelledby="view-show-label"> <span class="hc-menu__label" id="view-show-label">Show</span> <button class="hc-menu__item" role="menuitemcheckbox" type="button" aria-checked="true">Toolbar</button> <button class="hc-menu__item" role="menuitemcheckbox" type="button" aria-checked="false">Sidebar</button> </div>
<hr class="hc-menu__separator">
<div role="group" aria-labelledby="view-density-label"> <span class="hc-menu__label" id="view-density-label">Density</span> <button class="hc-menu__item" role="menuitemradio" type="button" aria-checked="true">Comfortable</button> <button class="hc-menu__item" role="menuitemradio" type="button" aria-checked="false">Compact</button> <button class="hc-menu__item" role="menuitemradio" type="button" aria-checked="false">Dense</button> </div></div>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.
Destructive items
Section titled “Destructive items”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>Submenus
Section titled “Submenus”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):
| Action | Result |
|---|---|
| Hover the parent | Opens the submenu (focus stays put). |
| Click, Enter, Space, or → | Opens it and focuses the first item. |
| ← | Closes the submenu, focus returns to the parent. |
| Esc | Closes the submenu (then the root). |
| Hovering a sibling | Closes the open submenu. |
| Selecting any leaf | Closes 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.
htmx usage
Section titled “htmx usage”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.
Accessibility
Section titled “Accessibility”- Always associate the menu with its trigger using
aria-labelledbypointing at the trigger’sid. - Every menu item needs
role="menuitem". Use a real<button>(or<a>for navigation menus) so the item is reachable withouttabindex. - Disable items by either the native
disabledattribute oraria-disabled="true". Both are skipped by arrow navigation and ignored byhc:menuselectclicks. - Don’t override the focus outline. The active item receives the
same focus background via
:focus-visible. menuitemcheckbox/menuitemradioitems keep their own checked state viaaria-checked(see Stateful items); the menu stays open after toggling one. Use thehc:menuselectevent to persist the change. Submenus are supported viadata-hc-submenu(see Submenus).
Theming tokens
Section titled “Theming tokens”Component tokens (in component.tokens.json):
| Token path | Purpose |
|---|---|
menu.bg / fg / border / radius | Menu surface. |
menu.padding-block | Vertical padding around the item list. |
menu.min-width / max-width | Surface constraints. |
menu.offset | Distance between trigger and menu (Anchor Positioning). |
menu.item.padding-x / padding-y / font-size / gap | Item layout. |
menu.item.fg / hover-bg / focus-bg / disabled-fg | Item states. |
menu.item.error-fg | data-variant="error" foreground. |
menu.item.indicator-size | Size 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">. |
CSS variables
Section titled “CSS variables”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)