Skip to main content
Dropdown menus, submenus, and menubars. Uses the Popover API for positioning.

Installation

npm install monochrome
import "monochrome"                        // Core (auto-activates)
import { Menu } from "monochrome/react"  // React
import { Menu } from "monochrome/vue"    // Vue

Usage

import { Menu } from "monochrome/react"

<Menu.Root>
  <Menu.Trigger>Open Menu</Menu.Trigger>
  <Menu.Popover>
    <Menu.Item>Action</Menu.Item>
    <Menu.Item disabled>Disabled</Menu.Item>
    <Menu.Item href="/link">Link</Menu.Item>
    <Menu.Separator />
    <Menu.CheckboxItem checked={false}>
      Bold
    </Menu.CheckboxItem>
    <Menu.RadioItem checked>Small</Menu.RadioItem>
    <Menu.RadioItem checked={false}>
      Large
    </Menu.RadioItem>
    <Menu.Separator />
    <Menu.Label>Section</Menu.Label>
    <Menu.Group>
      <Menu.Trigger>Submenu</Menu.Trigger>
      <Menu.Popover>
        <Menu.Item>Sub Action</Menu.Item>
      </Menu.Popover>
    </Menu.Group>
  </Menu.Popover>
</Menu.Root>
Set menubar (React) or :menubar="true" (Vue) on Menu.Root to create a menubar:
<Menu.Root menubar>
  <Menu.Popover> {/* renders as <ul role="menubar"> */}
    <Menu.Group>
      <Menu.Trigger>File</Menu.Trigger>
      <Menu.Popover>
        <Menu.Item>New</Menu.Item>
        <Menu.Item>Open</Menu.Item>
      </Menu.Popover>
    </Menu.Group>
    <Menu.Group>
      <Menu.Trigger>Edit</Menu.Trigger>
      <Menu.Popover>
        <Menu.Item>Undo</Menu.Item>
      </Menu.Popover>
    </Menu.Group>
  </Menu.Popover>
</Menu.Root>

API Reference

Root

The root container for the menu.
PropTypeDefaultDescription
menubarbooleanfalseMenubar mode

Trigger

The button that opens the menu or submenu. No props.

Popover

The menu container. Renders as <ul role="menu"> (or role="menubar" in menubar mode). No props.

Item

A menu action. Renders as <button>, <a> (with href), or <span> (with disabled).
PropTypeDefaultDescription
disabledbooleanfalseNon-interactive, skipped by keyboard
hrefstringRenders as <a> instead of <button>
Click closes the menu. Use e.stopPropagation() in the click handler to prevent closing.

CheckboxItem

A toggle menu item with role="menuitemcheckbox".
PropTypeDefaultDescription
checkedbooleanfalseChecked state
disabledbooleanfalseNon-interactive, skipped by keyboard
Click toggles aria-checked, menu stays open.

RadioItem

A radio menu item with role="menuitemradio".
PropTypeDefaultDescription
checkedbooleanfalseSelected state
disabledbooleanfalseNon-interactive, skipped by keyboard
Radio groups are implicit by DOM adjacency — separators or non-radio items break the group.

Label

A non-interactive heading with role="presentation". No props.

Separator

A visual divider with role="separator". Skipped by keyboard. No props.

Group

Wraps a submenu trigger + popover pair. No props.

DOM Structure

Every menu item must be wrapped in <li role="none"> — the core expects this structure.
Root → Trigger (button) [aria-haspopup=menu]
     → Popover (ul) [role=menu, popover=manual]
          → li [role=none] → menuitem (button|a|span)
          → li [role=separator]
          → li [role=none] (Group) → Trigger + Popover (submenu)

Keyboard Navigation

KeyAction
ArrowDownMove focus to next menu item
ArrowUpMove focus to previous menu item
HomeMove focus to first menu item
EndMove focus to last menu item
ArrowRightOpen submenu
ArrowLeftClose submenu
Enter or SpaceActivate focused item
EscapeClose menu
TabClose all menus
Letter keysTypeahead search
Keyboard opens with focus on first item, mouse opens with focus on trigger (distinguishes via event.detail).

Styling

Menu requires CSS for positioning. The core sets CSS custom properties via getBoundingClientRect:
/* Menu popover positioning */
[role="menu"] {
  position: fixed;
  inset: auto;
  margin: 0;
  top: var(--bottom);
  left: var(--left);
}

/* Submenu positioning */
[role="menu"] [role="menu"] {
  top: var(--top);
  left: var(--right);
  margin-left: 8px;  /* gap so core detects opening direction */
}

/* Popover visibility */
[popover]:popover-open {
  display: flex;
}
For submenu hover safety triangles, style [data-safe] with a clip-path polygon using the CSS custom properties the core sets (--left, --center, --right, --top, --bottom).

Safety Triangle

When a submenu is open, the core creates a triangular safe zone so users can move diagonally to it without accidentally closing it.
  1. The core calculates the submenu’s bounding rect and direction
  2. Sets data-safe attribute + CSS custom properties on the Group element
  3. Each pointermove updates the triangle point to follow the cursor
  4. Touch events (pointerType === "touch") are ignored

Accessibility

  • Uses role="menu" and role="menuitem" for proper semantics
  • Uses aria-haspopup="menu" on triggers
  • Uses aria-expanded to indicate submenu state
  • Uses aria-controls to link trigger to menu
  • Uses aria-labelledby to associate menu with trigger
  • Uses aria-disabled="true" for disabled items (not HTML disabled attribute)
  • Disabled items render as <span> (not <button>) to prevent click bubbling
  • Uses roving tabindex — only one item is focusable at a time
In Vue, menu item text must be inline (<Menu.Item>Apple</Menu.Item>) to avoid leading whitespace breaking textContent.startsWith() typeahead.

Browser Requirements

Requires Baseline 2024 features:
  • Popover API — menu positioning

Preventing Menu Close

Call e.stopPropagation() on an item’s click handler to prevent the menu from closing:
<Menu.Item onClick={(e) => {
  e.stopPropagation()
  // Do something without closing the menu
}}>Keep Open</Menu.Item>

Build docs developers (and LLMs) love