Menu
A list of actions opened from a button, built on Base UI Menu, that draws neutral chrome and keeps every safety-relevant action out on the screen.
Preview
menu · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/menu
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Menu } from "@/components/ui/menu"<Menu
trigger={<button type="button">Actions</button>}
items={[
{ label: "Share", onClick: onShare },
{ label: "Rename", onClick: onRename },
{ label: "Remove from list", separatorBefore: true, onClick: onRemove },
]}
/>When to use it
Use it when
- A small set of commands on one thing, such as Share, Rename and Duplicate.
- An overflow trigger on a dense list or toolbar, common actions left on screen.
- A grouped set of neutral actions, with a separator marking a related run.
Do not use it when
Anatomy
Menu.TriggerThe caller's own button, merged with open behaviour and aria state.Controlled bytriggerMenu.PopupThe floating list, portalled below the trigger, capped with scroll.Controlled byclassNameMenu.ItemOne action, muted when disabled, floored at 44pt.Controlled byitemsMenu.SeparatorA hairline above an item with separatorBefore set.
- Menu.Trigger 1data-slot="menu-trigger", one tab stop
- Menu.Popup 1data-slot="menu-popup", role="menu", holds focus
- Menu.Separator 0..ndata-slot="menu-separator", never before the first item
- Menu.Item 1..ndata-slot="menu-item", role="menuitem"
Examples
Actions from a button
Neutral commands fold behind an ordinary button passed as trigger; each
onClick runs straight from the list.
menu-actions-from-a-button · base base · style base-lyraOpen under the product themeA grouped list with a separator
separatorBefore draws a hairline above an item, grouping by structure rather
than colour. A disabled item shows muted rather than removed.
menu-with-a-separator · base base · style base-lyraOpen under the product themeContent guidelines
Name each action as an imperative, Rename not Renaming, common ones first. A destructive action carries its object, Remove from list not Remove: no tint marks it.
Do
"Share" · "Rename" · "Remove from list" names the object.
Don’t
A red "Delete" item borrows the status axis.
Accessibility
Checked by pnpm run check:a11y on every commit. No colour literal, no
px type size, every item floored at --opsin-target-minimum.
Audited against WCAG 2.2 AA, source and rendered, author-run and not independent. Clinical review is still pending. The rendered pass (axe, target size, focus, reflow) came back clean.
- Base UI renders
aria-haspopup,role="menu"androle="menuitem", and wires the popup's accessible name to the trigger. - The highlighted item takes real keyboard focus, so a house focus ring on
focus-visiblemarks it, drawn inward so the scroll clip never cuts it. - A disabled row drops to the muted ink; Escape and a choice both return focus to the trigger.
Known gaps. The roving highlight fill sits near 1.13:1 against the card, short of the 3:1 a state cue needs; the focus ring covers that case for now. An icon-only trigger ships an unnamed menu, so give it a label. Every contrast pair remains unmeasured.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus onto the trigger | A single tab stop. |
| Enter, Space, Arrow Down | Opens the menu, highlights the first item | Arrow Up opens on the last item. |
| Arrow Down, Arrow Up | Moves the highlight between items | Wraps and skips disabled items. |
| Enter, Space | Runs the highlighted item and closes the menu | Focus returns to the trigger. |
| Escape | Closes the menu without running anything | Focus returns to the trigger. |
| A to Z | Highlights the next item starting with that letter | Typeahead only. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
Data attributes
| Attribute | Condition | Value |
|---|---|---|
| data-slot | Always | menu-trigger, menu-popup, menu-item, menu-separator |
| data-highlighted | Item under the keys or pointer | Drives the highlight |
| data-disabled | Item with disabled set | Drives the muted ink |
API reference
Prop
Type
Generated from MenuProps in registry/bases/base/menu.tsx.
trigger must be a single focusable element. No items, one item, or an
enabled item with no onClick raises a development warning. className
merges last and carries no status or category tint.
Related
Popover
A small panel anchored to the control that opened it, floated in a portal and dismissed by leaving it, built as a thin themed wrapper over Base UI Popover.
SegmentedControl
A row of mutually exclusive options with exactly one chosen at a time, for switching a single view between a small set of windows.