hono-preact

Menu

A button-triggered dropdown of commands: a list of actions, toggles, and single-select choices that opens from a trigger and closes when you pick something or dismiss it. Positioning runs on Floating UI; the popup renders in place and promotes to the browser top layer using the Popover API. It ships unstyled: style it through the data-state, data-highlighted, data-disabled, data-side, and data-align contract.

The popup uses the role="menu" pattern, which is for application and command menus (a list of actions to perform), not for site navigation. For a set of links between pages, use a plain list of anchors so each link is a real <a> in the tab order; reserve Menu for commands.

Demo

Usage

import { Menu } from '@hono-preact/ui';

export function Actions() {
  return (
    <Menu.Root>
      <Menu.Trigger>Actions</Menu.Trigger>
      <Menu.Positioner>
        <Menu.Popup aria-label="Actions">
          <Menu.Item onSelect={() => create()}>New file</Menu.Item>
          <Menu.Item onSelect={() => open()}>Open</Menu.Item>
          <Menu.Separator />
          <Menu.CheckboxItem defaultChecked>Word wrap</Menu.CheckboxItem>
          <Menu.Separator />
          <Menu.Group>
            <Menu.GroupLabel>Density</Menu.GroupLabel>
            <Menu.RadioGroup defaultValue="comfortable">
              <Menu.RadioItem value="comfortable">Comfortable</Menu.RadioItem>
              <Menu.RadioItem value="compact">Compact</Menu.RadioItem>
            </Menu.RadioGroup>
          </Menu.Group>
          <Menu.Separator />
          <Menu.SubmenuRoot>
            <Menu.SubmenuTrigger>Share</Menu.SubmenuTrigger>
            <Menu.SubmenuPositioner>
              <Menu.SubmenuPopup aria-label="Share">
                <Menu.Item>Copy link</Menu.Item>
                <Menu.Item>Email</Menu.Item>
              </Menu.SubmenuPopup>
            </Menu.SubmenuPositioner>
          </Menu.SubmenuRoot>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Root>
  );
}

Menu.Item activates and closes the menu on click or Enter; call event.preventDefault() in its onSelect to keep the menu open after a choice. Menu.CheckboxItem and Menu.RadioItem carry their own checked state (each takes a controlled checked/value pair or an uncontrolled defaultChecked/ defaultValue). A Menu.SubmenuRoot nests another menu off a trigger row.

Styling

Parts expose data-state (open/closed on the popup and submenu trigger; checked/unchecked on checkbox and radio items). Items expose data-highlighted while active and data-disabled when disabled; the Positioner and Arrow expose data-side and data-align. The Positioner is the fixed-positioned wrapper, so size, z-index, and the entry animation go on the Popup inside it. The demo above uses the styles below; copy a starting point in either flavor:

.docs-menu-positioner {
  z-index: 50;
}
.docs-menu {
  box-sizing: border-box;
  min-width: 12rem;
  padding: 0.25rem;
  border: 1px solid #e4e4e7;
  border-radius: 0.625rem;
  background: #fff;
  color: #18181b;
  box-shadow:
    0 10px 15px -3px rgb(0 0 0 / 0.25),
    0 4px 6px -4px rgb(0 0 0 / 0.25);
  outline: none;
  opacity: 1;
  transform: translateY(0);
  transition:
    opacity 120ms ease,
    transform 120ms ease;
}
@starting-style {
  .docs-menu[data-state='open'] {
    opacity: 0;
    transform: translateY(-4px);
  }
}
.docs-menu[data-state='closed'] {
  opacity: 0;
  transform: translateY(-4px);
}
/* Items are roving-tabindex rows; the active row is highlighted via the
   data-attribute the component sets on hover and arrow navigation. */
.docs-menu__item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.875rem;
  padding: 0.4rem 0.6rem;
  border-radius: 0.375rem;
  cursor: pointer;
  outline: none;
}
.docs-menu__item[data-highlighted] {
  background: #18181b;
  color: #fafafa;
}
.docs-menu__item[data-disabled] {
  color: #a1a1aa;
  pointer-events: none;
}
.docs-menu__separator {
  height: 1px;
  margin: 0.25rem 0.3rem;
  background: #e4e4e7;
}
.docs-menu__label {
  padding: 0.25rem 0.6rem;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: #71717a;
}
@media (prefers-color-scheme: dark) {
  .docs-menu {
    border-color: #27272a;
    background: #18181b;
    color: #fafafa;
  }
  .docs-menu__item[data-highlighted] {
    background: #fafafa;
    color: #18181b;
  }
  .docs-menu__separator {
    background: #27272a;
  }
}
@media (prefers-reduced-motion: reduce) {
  .docs-menu {
    transition: none;
  }
}

Keyboard

KeysWhenAction
Enter / Space / / Trigger, closedOpen the menu. focuses the first item, the last.
/ Menu openMove to the next / previous item, wrapping at the ends.
Home / EndMenu openMove to the first / last item.
printable charactersMenu openTypeahead: focus the next item whose label starts with the typed text.
Enter / SpaceOn an itemActivate the highlighted item.
EscapeMenu openClose the menu and return focus to the trigger.
TabMenu openClose the menu, then move focus to the next element.
On a submenu triggerOpen the submenu and focus its first item.
In a submenuClose the submenu and return focus to its trigger.
EscapeIn a submenuClose the innermost open menu first.

Set loop={false} on Menu.Root to stop arrow navigation from wrapping, and typeahead={false} to disable type-to-focus.

Data attributes

AttributeOnValues
data-stateTrigger, Popup, SubmenuTriggeropen | closed
data-stateCheckboxItem, RadioItemchecked | unchecked
data-highlightedItem, CheckboxItem, RadioItem, SubmenuTriggerpresent while the item is the active row
data-disabledItem, CheckboxItem, RadioItempresent when the item is disabled
data-sidePositioner, Arrowtop | right | bottom | left (resolved)
data-alignPositionerstart | center | end (resolved)

API reference

Every part accepts a render prop for composition (see useRender) and forwards unknown props to the element it renders. Items pass their { disabled, highlighted } (plus checked on checkbox / radio items) state to a render function.

Menu.Root

Provides open state, ids, refs, navigation config, and placement to the parts. Renders only its children.

PropTypeDefaultDescription
openboolean-Controlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void-Called when the menu requests an open or close.
side'top'|'right'|'bottom'|'left''bottom'Preferred side of the trigger to place the popup.
align'start'|'center'|'end''start'Alignment along that side.
offsetnumber8Gap in pixels between the trigger and the popup.
loopbooleantrueWrap arrow navigation at the first / last item.
typeaheadbooleantrueFocus items by typing their leading characters.
childrenComponentChildren-The trigger and positioner.

Menu.Trigger

Toggles the menu on click and anchors it. Default element <button type="button">.

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
childrenComponentChildren-Trigger label.
...propsJSX.HTMLAttributes<HTMLButtonElement>-Forwarded to the element; a passed onClick runs before the menu toggles.

Sets aria-haspopup="menu", aria-expanded, id, data-state, and aria-controls (only while open, since the popup is mounted on open).

Menu.Positioner

The fixed-positioned wrapper that Floating UI drives. Renders nothing until the menu is open (mount on open). Default element <div>.

PropTypeDefaultDescription
renderRenderProp<{ side: Side; align: Align }>-Compose or replace the element.
childrenComponentChildren-The popup (and optional arrow).
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element. Style positioning via class.

Sets position: fixed and the resolved data-side / data-align. The element is promoted to the top layer via the Popover API, so it escapes ancestor clipping.

Menu.Popup

The menu surface and navigation root. Default element <div> with role="menu".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
aria-labelstring-Accessible name. Defaults to the trigger's label.
childrenComponentChildren-Items, separators, groups, and submenus.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets role="menu", id, tabindex="-1", aria-orientation="vertical", data-state, and aria-labelledby (the trigger) or aria-label. Owns the keyboard navigation, typeahead, Escape / outside-press dismissal, and focus move-in / return.

Menu.Item

A command row. Default element <div> with role="menuitem".

PropTypeDefaultDescription
renderRenderProp<{ disabled: boolean; highlighted: boolean }>-Compose or replace the element.
disabledbooleanfalseSkip the item in navigation and ignore activation.
onSelect(event: Event) => void-Called on activation. Call event.preventDefault() to keep the menu open.
childrenComponentChildren-Item content.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Menu.CheckboxItem

A toggle row that holds its own checked state. Default element <div> with role="menuitemcheckbox".

PropTypeDefaultDescription
renderRenderProp<{ checked: boolean; disabled: boolean; highlighted: boolean }>-Compose or replace the element.
checkedboolean-Controlled checked state. Pair with onCheckedChange.
defaultCheckedbooleanfalseInitial checked state when uncontrolled.
onCheckedChange(checked: boolean) => void-Called when the checked state changes.
disabledbooleanfalseSkip the item and ignore activation.
onSelect(event: Event) => void-Called on activation; preventDefault() keeps the menu open.
childrenComponentChildren-Item content (render your own check indicator).
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets aria-checked and data-state="checked" | "unchecked".

Menu.RadioGroup

Single-select context for the radio items inside it. Default element <div> with role="group".

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
valuestring-Controlled selected value. Pair with onValueChange.
defaultValuestring-Initial selected value when uncontrolled.
onValueChange(value: string) => void-Called when the selection changes.
childrenComponentChildren-The radio items.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Menu.RadioItem

A choice within a Menu.RadioGroup. Default element <div> with role="menuitemradio".

PropTypeDefaultDescription
valuestringrequiredIdentifies the item within its group.
renderRenderProp<{ checked: boolean; disabled: boolean; highlighted: boolean }>-Compose or replace the element.
disabledbooleanfalseSkip the item and ignore activation.
onSelect(event: Event) => void-Called on activation; preventDefault() keeps the menu open.
childrenComponentChildren-Item content (render your own selected indicator).
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets aria-checked and data-state="checked" | "unchecked".

Menu.Separator

A horizontal divider between groups of items. Default element <div> with role="separator".

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Menu.Group

Wraps related items and labels them with a Menu.GroupLabel. Default element <div> with role="group".

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
childrenComponentChildren-A Menu.GroupLabel and the items.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Wires its label to aria-labelledby.

Menu.GroupLabel

The accessible name for a Menu.Group. Presentational, not focusable. Default element <div>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
childrenComponentChildren-Label text.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Menu.Arrow

Optional pointer positioned from the Floating UI arrow data. Default element <div>. Place it on the correct edge with CSS keyed on data-side.

PropTypeDefaultDescription
renderRenderProp<{ side: Side }>-Compose or replace the element.
childrenComponentChildren-Optional arrow content.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets data-side and position: absolute with the computed offset.

Menu.SubmenuRoot

Provides a nested menu's open state and placement off a trigger row. Must be placed inside a Menu.Popup. Renders only its children.

PropTypeDefaultDescription
openboolean-Controlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void-Called when the submenu requests an open or close.
side'top'|'right'|'bottom'|'left''right'Preferred side of the trigger to place the submenu.
align'start'|'center'|'end''start'Alignment along that side.
offsetnumber0Gap in pixels between the trigger and the submenu.
openDelaynumber100Hover open delay in ms.
closeDelaynumber300Safe-corridor grace in ms before closing on pointer leave.
childrenComponentChildren-The submenu trigger and positioner.

Menu.SubmenuTrigger

The row that opens the submenu. It is itself a menuitem in the parent menu. Default element <div> with role="menuitem".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean; highlighted: boolean }>-Compose or replace the element.
disabledbooleanfalseSkip the row and ignore activation.
childrenComponentChildren-Row content.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets aria-haspopup="menu", aria-expanded, and (while open) aria-controls. Opens on hover (after openDelay), , Enter, Space, or click; the safe corridor keeps it open while the pointer travels toward the submenu.

Menu.SubmenuPositioner

The submenu's fixed-positioned wrapper. Same surface as Menu.Positioner, bound to the submenu. Default element <div>.

PropTypeDefaultDescription
renderRenderProp<{ side: Side; align: Align }>-Compose or replace the element.
childrenComponentChildren-The submenu popup.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element. Style positioning via class.

Menu.SubmenuPopup

The submenu surface. Same surface as Menu.Popup, with wired to close the submenu and return focus to its trigger. Default element <div> with role="menu".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
aria-labelstring-Accessible name. Defaults to the submenu trigger.
childrenComponentChildren-The nested items.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Primitives

@hono-preact/ui exports the building blocks the parts use, for composing your own components. Several have their own page with examples: usePosition, useDismiss, useFocusReturn, useSafeArea, useRender, useControllableState, and mergeRefs.

Accessibility

The menu follows the ARIA menu-button pattern. The trigger carries aria-haspopup="menu", aria-expanded, and (while open) aria-controls; the popup is a role="menu" with aria-orientation="vertical", named by the trigger or an explicit aria-label. Items are menuitem, menuitemcheckbox, and menuitemradio.

  • Both Enter / Space and the arrow keys open the menu from the trigger, so it is reachable without a pointer.
  • Focus moves into the menu when it opens (the first or last item) and returns to the trigger when it closes. The items use a roving tabindex, so only the active item is in the tab order.
  • Escape closes the menu, an outside pointer press closes it, and nested submenus close innermost-first.
  • Use the role="menu" pattern only for commands. For navigation between pages, render a list of links instead, so each destination is a real <a>.