Skip to main content

Menu

betav0.1.0

Dropdown menu of actions triggered by a button. Compound-component API built on @radix-ui/react-dropdown-menu, with grouped items, group headers, separators, leading/trailing icon slots and a selected-item indicator. Covers the action-menu and single-selection patterns; sub-menus, checkbox/radio items and position-anchored context menus are explicitly out of scope in v1 (Menu.rfc.md §7).

Variants

Default — an IconButton trigger and a panel of actions. Open it and walk the rows with the arrow keys: the ring only appears for the keyboard.

Anatomy

The ten exports a menu is assembled from. Each block names one part, states its job, lists the states it really has, and opens a live menu that exercises it — hover and arrow through them, the panels are the shipped components, not pictures of them.
<Menu>The root. Owns open state, modality and reading direction; renders nothing itself.
closedopenmodalnon-modal
Open and press Escape — the only keyboard dismissal. Tab stays trapped inside.
<MenuTrigger>The control that opens the panel. Has no visual treatment of its own — always pass asChild and wrap a real library control.
restopen (aria-expanded)
Always asChild: without it Radix renders a bare native button that no design-system policy reaches.
<MenuContent>The floating panel. Owns the surface — fill, radius, 4px vertical padding, 200px min / 300px max width — and caps its height at the space measured below the trigger.
defaultscrolling (list taller than the viewport)
This one is deliberately long: past the available height it scrolls instead of overflowing.
<MenuItem>The row: a 36px floor, two optional icon slots and a label that wraps rather than truncating.
resthighlighted (hover AND keyboard — one attribute)disabledselectedwrapped label
Arrow through it: the disabled row is skipped by both the arrow keys and typeahead. The last row shows the wrap — the row grows instead of clipping, and keeps 4px clear of both edges.
<MenuGroup>Wraps a set of related rows so assistive technology announces them as one group. Adds no spacing of its own.
labelledunlabelled
The library wires aria-labelledby from the MenuLabel inside it — Radix does not do this for you.
<MenuLabel>The group heading. A 10px caption, bottom-aligned in a 32px band. Goes INSIDE its MenuGroup — that placement is what names the group.
default
Not interactive and not focusable: it is a heading, not a row.
<MenuSeparator>A 1px rule dividing groups, with 4px of space on each side.
default
Decorative and aria-hidden: it divides visually, MenuGroup divides semantically.
<MenuSub / MenuSubTrigger / MenuSubContent>A row that opens a second panel sideways. MenuSub pairs the two; MenuSubTrigger is the row (same 36px geometry as MenuItem, plus a trailing chevron); MenuSubContent is the child panel.
resthighlightedopendisabled
Arrow-right opens and moves into the child; arrow-left closes it and returns focus to this row. Escape closes one level, not the whole menu.
Three levels of nesting, as drawn in node 6716:81557. Open it and walk it with the arrow keys alone: right descends, left ascends, up and down stay inside the level you are on, and Escape closes one panel at a time — not the whole menu. The row you came from stays highlighted while its child panel is open, so the path you took is always visible.
Level 1 → “Move to workspace” → “Regional teams”. The child panel is the same surface as its parent, offset 4px clear of it.

Examples

// Action menu — the pattern behind 80% of the audited product menus
<Menu>
  <MenuTrigger asChild>
    <IconButton
      aria-label="Open actions"
      priority="secondary"
      icon={<Icon name="ellipsis-vertical" size={16} />}
    />
  </MenuTrigger>
  <MenuContent>
    <MenuItem startIcon={<Icon name="pencil" size={16} />} onSelect={onEdit}>
      Edit
    </MenuItem>
    <MenuItem startIcon={<Icon name="files" size={16} />} onSelect={onDuplicate}>
      Duplicate
    </MenuItem>
    <MenuSeparator />
    <MenuItem startIcon={<Icon name="trash-2" size={16} />} onSelect={onDelete}>
      Delete
    </MenuItem>
  </MenuContent>
</Menu>
// Real anchors — asChild keeps ⌘-click, middle-click and "open in new tab"
// working, which an onSelect + router.push() item silently breaks.
<MenuItem asChild>
  <a href="https://docs.example.com" target="_blank" rel="noreferrer">
    Documentation
  </a>
</MenuItem>
// Selection — renders a check indicator and sets aria-current="true"
<Menu>
  <MenuTrigger asChild>
    <Button variant="outlined">{tenant.name}</Button>
  </MenuTrigger>
  <MenuContent>
    <MenuGroup>
      <MenuLabel>Tenants</MenuLabel>
      {tenants.map((t) => (
        <MenuItem key={t.id} selected={t.id === current} onSelect={() => switchTo(t.id)}>
          {t.name}
        </MenuItem>
      ))}
    </MenuGroup>
  </MenuContent>
</Menu>
// Explaining a disabled item: Radix sets pointer-events-none on the item
// itself, so the tooltip must wrap it from the OUTSIDE (RULE-04).
<Tooltip title="You need admin rights">
  <span>
    <MenuItem disabled>Delete workspace</MenuItem>
  </span>
</Tooltip>

Props

PropType
Menu.openboolean — Controlled open state. Pair with onOpenChange.
Menu.defaultOpenboolean — Uncontrolled initial open state.
Menu.onOpenChange(open: boolean) => void — Called when the menu opens or closes. This is the replacement for MUI's onClose: it fires for every dismissal path — Escape, outside click, item selection. Note one divergence from MUI when migrating: Tab does NOT dismiss the menu, because Radix traps it inside the open panel (RFC §5.2). Escape is the only keyboard dismissal.
Menu.modalboolean (default true) — Radix modal behaviour: scroll lock plus aria-hidden on the rest of the page. Set to false for a menu inside a scrollable container the user must keep scrolling.
Menu.dir"ltr" | "rtl" — Reading direction; inverts the arrow keys.
MenuTrigger.asChildboolean — Always pass it. The trigger has no visual treatment of its own; it is meant to wrap a real library component (IconButton, Button, ToggleButton…). Omitting it makes Radix render an unstyled native <button> that no design-system policy reaches, and warns in development.
MenuContent.portalledboolean (default true) — Render the panel in a portal. Set to false only for a host that clips or restyles portalled content.
MenuContent.side / .align / .sideOffsetRadix positioning. Defaults: side="bottom", align="start", sideOffset={8}. The panel is 200px min / 300px max wide and caps its height at the space Radix measures below the trigger, scrolling beyond that.
MenuContent.loopboolean (default true) — Arrow-key navigation wraps from the last item to the first and back. Radix's own default is false; the library flips it so migrating MUI menus keep the wrapping their users already have.
MenuContent.onEscapeKeyDown / .onPointerDownOutside / .onInteractOutsideRadix dismissal hooks. Call event.preventDefault() to keep the menu open (e.g. a destructive action that opens a confirmation first).
MenuItem.onSelect(event: Event) => void — Fires on click, Enter and Space. The menu closes afterwards unless you call event.preventDefault().
MenuItem.asChildboolean — Render the row as the child element instead of a div. Use it for real navigation: <MenuItem asChild><a href=…>…</a></MenuItem> keeps ⌘-click, middle-click and the browser context menu working. The row layout stays the library's — the child receives the composed content.
MenuItem.startIconReactNode — Leading slot, typically <Icon name=… size={16} />.
MenuItem.endIconReactNode — Trailing slot. Ignored while selected is true (one slot, one occupant) and warns in development if both are supplied.
MenuItem.selectedboolean — Marks the row as the current item: renders a check indicator in the trailing slot and sets aria-current="true".
MenuItem.disabledboolean — Skipped by arrow navigation and typeahead, and Radix sets pointer-events-none on the row, so a tooltip explaining why must wrap the item from the outside.
MenuItem.textValuestring — Typeahead-matching text. Required when children are not plain text (e.g. a row containing an avatar plus a name).
MenuGroup.childrenReactNode — One or more MenuItem, grouped under a MenuLabel. Radix does NOT associate its own Label with its Group, so this library wires the group's aria-labelledby itself, and only once a MenuLabel has actually registered.
MenuLabel.childrenReactNode — Group header text. Not focusable and not selectable; it labels the group, it is not an item.
MenuSeparator (no props)Visual divider between groups of items — a single hairline, no configurable props beyond standard HTML div attributes.

Accessibility

2.1 AAWCAG pass

Keyboard: Enter, Space and ArrowDown open the menu and focus the first item; ArrowUp/ArrowDown walk the items and wrap in both directions (MenuContent defaults loop to true — Radix's own default is false, which would have been a silent regression for the 97 product menus migrating off MUI's wrapping Menu); Home/End jump to the ends; printable characters typeahead-match on the item's text, or on textValue when the row is not plain text; Escape closes and RETURNS FOCUS TO THE TRIGGER. Disabled items are skipped by both arrow navigation and typeahead, as are the group label and the separator. Two measured divergences from the WAI-ARIA menu-button pattern, both inherited from Radix and both left as-is in v1 rather than patched from the wrapper (each is pinned down by a test in Menu.keyboard.test.tsx so it stays on the record): ArrowUp on the CLOSED trigger does not open the menu — Radix's trigger handles only Enter, Space and ArrowDown; and Tab does NOT close an open menu — Radix's menu content calls preventDefault() on Tab, so focus stays trapped inside and Escape is the only keyboard dismissal. Products migrating off MUI's Menu, which does close on Tab, need to know the second one. ARIA: the trigger carries aria-haspopup="menu", aria-expanded and aria-controls; the panel is role="menu"; items are role="menuitem". The selected item is marked with aria-current="true" — not aria-selected or aria-checked, neither of which is valid on role="menuitem" without the menuitemradio/menuitemcheckbox roles v1 excludes. The check indicator itself is aria-hidden: the state is announced once, by aria-current, and announcing it twice would be noise. Focus visibility: the ring is applied under focus-visible, never focus. Radix moves real DOM focus onto an item on pointermove, so a plain focus: ring would paint on mouse hover; the browser's :focus-visible heuristic is what keeps it keyboard-only. Rows are flush, so the ring also raises z-10 to avoid being overpainted by the next row. Hover vs focus: Radix raises the SAME data-highlighted attribute for pointer hover and keyboard navigation, so only one of Figma's two fills can render. This component renders the HOVER fill, inverting Select's own choice, because Figma's Focus fill (--bg-input-default) aliases the panel's own background (--bg-elevation-highlight) and would make every hover invisible. Reasoning recorded in Menu.rfc.md §3.5 for the next panel component. Contrast, deliberate non-declarations: MenuSeparator's --border-elevation-subtle is a decorative structural divider, not a functional UI-component boundary — exempt from WCAG 1.4.11 per SC 1.4.11 Note 1, and the menu is fully operable without it (Select.meta.ts and Tabs.meta.ts document the identical exemption). Measured at 1.39:1 dark / 1.29:1 light, i.e. clearly a design intent, not an oversight. Contrast: the disabled row renders no fill (node 6609:47038 dims the label only; the fill it carried until the 2026-08-20 export is removed), and its remaining class (data-[disabled]:text-input-disabled) is permanently exempt from the contrast requirement — WCAG 1.4.3 Note 1 and 1.4.11 both exclude inactive user interface components — so no contrast pair is declared for them, deliberately and not by omission. Not covered in v1: sub-menus, checkbox items, radio items, position-anchored context menus and a dense size. See Menu.rfc.md §7 for the measured product volumetry behind each exclusion.