opsinjs
ComponentsOverlay

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

Theme
Density
Text
Status
menu · base base · style base-lyraOpen under the product theme

Installation

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

  • The action is safety-relevant or destructive, and a reader cannot weigh a command they cannot see.Use dialog instead.
  • There is a single primary action, so a menu is a button in hiding.Use button instead.
  • You want a docked sheet of actions on a phone.Use sheet instead.

Anatomy

  1. Menu.TriggerThe caller's own button, merged with open behaviour and aria state.Controlled by trigger
  2. Menu.PopupThe floating list, portalled below the trigger, capped with scroll.Controlled by className
  3. Menu.ItemOne action, muted when disabled, floored at 44pt.Controlled by items
  4. Menu.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.

Theme
Density
Text
Status
menu-actions-from-a-button · base base · style base-lyraOpen under the product theme

A 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.

Theme
Density
Text
Status
menu-with-a-separator · base base · style base-lyraOpen under the product theme

Content 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" and role="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-visible marks 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.

KeyActionNotes
TabMoves focus onto the triggerA single tab stop.
Enter, Space, Arrow DownOpens the menu, highlights the first itemArrow Up opens on the last item.
Arrow Down, Arrow UpMoves the highlight between itemsWraps and skips disabled items.
Enter, SpaceRuns the highlighted item and closes the menuFocus returns to the trigger.
EscapeCloses the menu without running anythingFocus returns to the trigger.
A to ZHighlights the next item starting with that letterTypeahead only.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor
body text on the canvas materiallight104.717.96:1Pass
body text on the canvas materialdark-100.518.00:1Pass
body text on the inset materiallight98.116.32:1Pass
body text on the inset materialdark-100.117.18:1Pass
body text on the card materiallight104.717.96:1Pass
body text on the card materialdark-99.616.32:1Pass
body text on the raised materiallight104.717.96:1Pass
body text on the raised materialdark-97.513.77:1Pass
body text on the sheet material over the darkest backdroplight99.916.76:1Pass
body text on the sheet material over the darkest backdropdark-99.716.47:1Pass
body text on the sheet material over the lightest backdroplight104.717.96:1Pass
body text on the sheet material over the lightest backdropdark-80.86.07:1Pass
body text on the overlay material over the darkest backdroplight85.913.48:1Pass
body text on the overlay material over the darkest backdropdark-99.916.77:1Pass
body text on the overlay material over the lightest backdroplight104.717.96:1Pass
body text on the overlay material over the lightest backdropdark-54.22.69:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

Data attributes

AttributeConditionValue
data-slotAlwaysmenu-trigger, menu-popup, menu-item, menu-separator
data-highlightedItem under the keys or pointerDrives the highlight
data-disabledItem with disabled setDrives 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.

  • Select chooses one value and keeps it shown; a menu runs and forgets.
  • Button is one action, where a safety-relevant command stays.
  • Dialog confirms an irreversible action; a menu item runs on one press.
  • Sheet docks actions to a phone's edge, large targets under the thumb.

On this page