Menu

A button that opens a list of actions anchored beside it — the desktop overflow, "more actions", and split-button pattern, with full arrow-key navigation and typeahead.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Menu } from '@design-schema/react';

Examples

import { Menu, type MenuItem } from '@design-schema/react';

const ITEMS: MenuItem[] = [
  { id: 'open', label: 'Open', icon: 'external' },
  { id: 'rename', label: 'Rename', shortcut: 'Ctrl+R' },
  { id: 'duplicate', label: 'Duplicate' },
  {
    group: 'Sort by',
    items: [
      { id: 'sort-name', label: 'Name' },
      { id: 'sort-date', label: 'Date modified' },
    ],
  },
  { separator: true },
  { id: 'archive', label: 'Archive', disabled: true },
  { id: 'delete', label: 'Delete', tone: 'danger' },
];

<Menu label="More actions" items={ITEMS} />

Props

8 rows
PropTypeDefaultDescriptionPlatforms
label requiredstring—

The trigger's label and the menu's accessible name ("More actions", "Sort by").

Accessibility: The accessible name of the trigger, and of the menu: in trigger mode the popup is named `aria-labelledby` the trigger (which this string names), and in `anchor` mode — and on Lit, where an idref cannot reach a slotted trigger — it is this string as the popup's `aria-label`. The two agree as long as the trigger keeps a name of its own, which an `iconOnly` Button takes from this prop.

web, lit, rn, swiftui
items requiredarray ({ id: string; label: string; icon?: IconName; shortcut?: string; tone?: "default" | "danger"; disabled?: boolean } | { group: string; items: MenuItem[] } | { separator: true })[]—

Actions, optionally grouped with a label or divided by separators. Groups render their label as a non-interactive heading row and hold action items only — the shape is recursive but a group inside a group is not a shape this component draws: a nested group or a separator inside a group is dropped without a development warning.

web, lit, rn, swiftui
triggerVariantghost | secondary | primaryghost

Variant of the trigger Button.

web, lit, rn, swiftui
triggerIconellipsis | chevron-down | nonechevron-down

Trailing icon on the trigger: `ellipsis` for an icon-only overflow button (the label becomes the accessible name), `chevron-down` for a labelled dropdown, `none`. With `iconOnly` the glyph is passed as the Button's `leadingIcon` (an icon-only Button shows only that); otherwise as its `trailingIcon`.

web, lit, rn, swiftui
iconOnlybooleanfalse

Render the trigger as an icon-only Button using `triggerIcon`; `label` is still required. With `triggerIcon: none` there would be nothing visible to press, so that pairing warns in development (once), unless `anchor` is set (there is no trigger then). It is the only development warning Menu issues — no warning for empty items, a missing label or an uncontrolled anchor.

web, lit, rn, swiftui
placementbottom-start | bottom-end | top-start | top-endbottom-start

Preferred position of the popup relative to the trigger (or `anchor`). Only the block side flips (bottom and top swap) when the popup would overflow the viewport; `start` and `end` never flip. They resolve against the layout direction read from the trigger itself — `getComputedStyle(trigger ?? anchor).direction` on web and Lit, I18nManager.isRTL on rn — so a menu inside a right-to-left subtree of a left-to-right page resolves against its own trigger, not the page (in right-to-left `start` is the right edge). The popup is shifted inline instead so it stays `gutter` away from the side edges; when the popup is wider than the viewport minus 2 × gutter the two constraints cannot both hold and the leading edge wins, clamped to `gutter` on the start side.

web, lit, rn, swiftui
openboolean—

Controlled open state (the parent flips it from onOpenChange). Omit for an uncontrolled menu, which starts closed; there is no defaultOpen. A controlled menu hides only when `open` becomes false — a parent that never flips it keeps the menu open. Focus on close follows the reason (Behavior); a close the menu did not request moves focus to the trigger only when focus is inside the popup at that moment.

web, lit, rn, swiftui
anchorobject RefObject<HTMLElement | View>—

Position the popup relative to this element instead of rendering a trigger; the trigger part is omitted and `open` must be controlled — an uncontrolled `anchor` menu has nothing that can open it, and that pairing does not warn (`iconOnly` is Menu's only development warning). Used by ActionSheet above its breakpoint and by context menus. The shape is per platform: web a `RefObject<HTMLElement | null>`; Lit an `anchor` property holding the element itself (`HTMLElement | undefined`, not an attribute); rn a ref to the host View instance (`RefObject<React.ComponentRef<typeof View> | null>`), measured with measureInWindow(). The anchor stands in for the trigger: a pointerdown on it is not `outside` and focus moving onto it is not `focus-out` (the consumer toggles `open` from it), and focus that would return to the trigger returns to the element that had focus when the menu opened — when nothing had focus (ActionSheet's wide presentation opens from a fresh render) focus is left where it is rather than moved to the anchor, which need not be focusable. React Native has no previously-focused-element query, so there accessibility focus returns to the anchor View itself, which the consumer must make focusable for it to land.

web, lit, rn, swiftui

Events

2 rows
EventTypeDescriptionPlatforms
onActiononAction / action

An item was chosen; receives its `id`. The menu closes itself first.

web, lit, rn, swiftui
onOpenChangeonOpenChange / open-change

Fired when the menu opens or closes, with positional arguments `(open, reason)` (Lit: detail `{ open, reason }`) — reason: `trigger`, `escape`, `outside`, `action` (an item was chosen; fired before onAction), `controlled`, `tab-out`, `focus-out`. overlay.dismiss is the shared category vocabulary; it maps to reasons as escape → `escape`, outside-press → `outside`, and focus-out → `tab-out` (Tab/Shift+Tab) or `focus-out` (any other focus loss, including the window losing focus).

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Required props that carry accessibility meaning: label. Each is described in the props table above.

Implicit ARIA role: menu.

Anatomy

triggerpopuplistgroupgroupLabelitemitemIconitemShortcutseparator

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Expanded state — Open and closed are exposed programmatically (`aria-expanded`), not only visually.

Arrow navigation — Arrow keys move between items; Home and End jump to the ends.

Roving tabindex — The group is a single tab stop; the arrow keys move within it.

Escape dismisses — Escape closes it from anywhere inside.

Focus restore — On close, focus returns to whatever opened it.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.

Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).

No hover-only content — Nothing is reachable by hover alone; keyboard focus reveals the same content.

Menu keyboard support

11 rows
KeyAction
Enter or Space or ArrowDownOpens the menu and focuses the first item (ArrowDown on an already open menu just focuses the first item). (focus on trigger)
ArrowUpOpens the menu and focuses the last item (on an already open menu it just focuses the last item). (focus on trigger)
ArrowDownMoves to the next enabled item. (menu open)
ArrowDownFrom the last item wraps to the first. (menu open)
ArrowUpFrom the first item wraps to the last. (menu open)
HomeFirst enabled item. (menu open)
EndLast enabled item. (menu open)
Enter or SpaceActivates the focused item and closes. (menu open)
EscapeCloses and returns focus to the trigger; pressed on the trigger while the menu is open it also closes (reason `escape`) and focus stays there. The key is prevented and its propagation stopped in both places, so a Menu inside a Dialog closes only the menu. (menu open)
Tab or Shift+TabCloses; Tab moves focus to the tabbable element after the trigger, Shift+Tab to the one before it, in document order (the anchor stands in for the trigger when there is none). The key is not prevented: the menu sets every item to tabindex -1 and moves focus to the trigger, so the browser's own Tab continues from there and a popup a controlled parent still shows holds no tab stop (web and Lit alike) — that is a suppression state of its own, not a roving 0 parked somewhere, and the next open starts from the first item again. The focusout this parking causes is part of the Tab: it reports `tab-out` only, never a second `focus-out`. With `anchor`, focus is parked on the anchor when it is focusable; otherwise the menu prevents the key and focuses the first tabbable after (Tab) or the last before (Shift+Tab) the anchor in document order, excluding its descendants. (menu open)
a-zTypeahead — moves to the next enabled item whose label starts with the typed characters. Any single printable character counts (letters of any script and digits, not only a–z), compared case-insensitively; Space stays activation, and keys held with Ctrl, Meta or Alt are ignored. (menu open)

Contrast pairs

5 rows
ForegroundBackgroundLevel
color.foregroundcolor.overlay.surfaceAA
color.foregroundcolor.background.subtleAA
color.foreground.dangercolor.overlay.surfaceAA
color.foreground.dangercolor.background.subtleAA
color.foreground.mutedcolor.overlay.surfaceAA

Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.