Skip to content

Actions & Overlays

Menu

A menubar with nested submenus.

stableEnhancer: enhanceMenuBuilt on <ul role="menubar">hydrateless/menu.css

A menubar or navigation menu with nested submenus, following the WAI-ARIA menubar pattern. Without JavaScript the stylesheet reveals submenus on hover and :focus-within. The enhancer marks the root data-hl-ready, takes over visibility, and wires a roving tabindex, orientation-aware arrow navigation, typeahead, checkable items, and disabled skipping. Submenus are promoted to native popovers so they render in the top layer, positioned against their trigger with CSS anchor positioning (with a JS fallback). Submenus nest to any depth: an item followed by a sibling data-hl-submenu opens it with Right (Left in RTL), and Left or Esc steps back out. The open top-level submenu is the menu's value.

Menubar

Arrow keys move between items; Enter, Space, or Down opens a submenu and Esc closes it. Each top-level trigger's data-hl-value is the value reported while its submenu is open.

Live
  • Help

JavaScript

This component ships an optional enhancer. With auto-init it loads automatically; to wire it yourself, import it directly:

import { enhanceMenu } from '@hydrateless/enhancers/menu';

const handle = enhanceMenu(document);
// handle.api  -> imperative controls
// handle.destroy()  -> tear everything down

Props

The framework bindings expose these props (and forward the rest to the root element).

PropTypeDefaultDescription
valuestring | nullNot setControlled open submenu (`null` when all are closed); pair with `onValueChange` (Vue: `v-model`, Svelte: `bind:value`).
defaultValuestring | nullnullSubmenu to open on mount, uncontrolled.
onValueChange(value: string | null) => voidNot setCalled with the open submenu value (or `null`) after every change.
onSelect(value: string, item: HTMLElement, checked?: boolean) => voidNot setCalled with the item value when a leaf item is activated. For checkable items, `checked` is the new state.
orientation'horizontal' | 'vertical''horizontal'Direction of the top-level items. Vertical menus open submenus to the side.
MenuSubmenu.labelstringNot setText of the top-level trigger that opens the submenu. Vue and Svelte also accept a `trigger` slot/snippet for rich content; React accepts any node.
MenuSubmenu.valuestringNot setValue reported by `onValueChange` while the submenu is open. Defaults to the top-level index.
MenuItem.valuestringNot setValue reported by `onSelect` when the leaf item is activated. Defaults to the item text.
MenuItem.hrefstringNot setRender the item as a link instead of a button.
MenuItem.role'menuitem' | 'menuitemcheckbox' | 'menuitemradio''menuitem'Plain action, toggle, or single-select item. Pair checkable roles with `checked`.
MenuItem.disabledbooleanfalseSkipped by arrow navigation and typeahead; cannot be activated.

Events and callbacks

Event / CallbackPayloadDescription
hl:change{ value: string | null }Fires when a submenu opens or closes, with the open submenu value (also `onValueChange`).
hl:select{ value: string, item: HTMLElement, checked?: boolean }Fires when a leaf item is activated. Cancelable.

CSS variables

Override these on any ancestor to theme the component. See the theme studio to preview changes live.

CSS variableUsed for
--hl-surfaceSubmenu background.
--hl-borderSubmenu border.
--hl-overlay-shadowSubmenu elevation.

Accessibility

  • A roving tabindex keeps the menubar a single tab stop.
  • Submenu triggers expose aria-haspopup="menu", aria-expanded, and aria-controls, at every level of nesting.
  • Before the enhancer runs, submenus open on hover and :focus-within, so the navigation is usable with no JavaScript.
  • Disabled items are skipped; menuitemcheckbox and menuitemradio keep aria-checked in sync.

Released under the MIT License.