Menu
betav0.1.0Dropdown 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
Anatomy
<Menu>The root. Owns open state, modality and reading direction; renders nothing itself.<MenuTrigger>The control that opens the panel. Has no visual treatment of its own — always pass asChild and wrap a real library control.<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.<MenuItem>The row: a 36px floor, two optional icon slots and a label that wraps rather than truncating.<MenuGroup>Wraps a set of related rows so assistive technology announces them as one group. Adds no spacing of its own.<MenuLabel>The group heading. A 10px caption, bottom-aligned in a 32px band. Goes INSIDE its MenuGroup — that placement is what names the group.<MenuSeparator>A 1px rule dividing groups, with 4px of space on each side.<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.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
| Prop | Type |
|---|---|
Menu.open | boolean — Controlled open state. Pair with onOpenChange. |
Menu.defaultOpen | boolean — 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.modal | boolean (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.asChild | boolean — 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.portalled | boolean (default true) — Render the panel in a portal. Set to false only for a host that clips or restyles portalled content. |
MenuContent.side / .align / .sideOffset | Radix 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.loop | boolean (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 / .onInteractOutside | Radix 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.asChild | boolean — 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.startIcon | ReactNode — Leading slot, typically <Icon name=… size={16} />. |
MenuItem.endIcon | ReactNode — Trailing slot. Ignored while selected is true (one slot, one occupant) and warns in development if both are supplied. |
MenuItem.selected | boolean — Marks the row as the current item: renders a check indicator in the trailing slot and sets aria-current="true". |
MenuItem.disabled | boolean — 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.textValue | string — Typeahead-matching text. Required when children are not plain text (e.g. a row containing an avatar plus a name). |
MenuGroup.children | ReactNode — 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.children | ReactNode — 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
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.