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
Props
8 rows| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | 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. |
| items required | array ({ 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. |
| triggerVariant | ghost | secondary | primary | ghost | Variant of the trigger Button. |
| triggerIcon | ellipsis | chevron-down | none | chevron-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`. |
| iconOnly | boolean | false | 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. |
| placement | bottom-start | bottom-end | top-start | top-end | bottom-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. |
| open | boolean | — | 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. |
| anchor | object 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. |
Events
2 rows| Event | Type | Description |
|---|---|---|
| onAction | onAction / action | An item was chosen; receives its `id`. The menu closes itself first. |
| onOpenChange | onOpenChange / 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). |
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
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.
Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.