---
title: "Menu"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/menu"
source: "https://opsinjs.pensievelabs.org/components/menu.md"
section: "Components"
status: "shipped"
kind: "component"
category: "overlay"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["dropdown menu", "context menu", "overflow menu"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="menu"
  status="shipped"
  questions="[
  &#x22;The refusal to hide a safety-relevant action is a convention the type system cannot enforce, so a caller can still pass a destructive command.&#x22;,
  &#x22;Nobody has listened to this menu in a screen reader, so whether a run set apart by a separator reads as a group has only been reasoned about.&#x22;,
  &#x22;No contrast pair has been measured for the popup against the surfaces it floats over, the highlighted row or the separator hairline.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="menu" />

## Installation [#installation]

<ComponentInstall name="menu" unbuilt="false" importPath="@/components/ui/menu" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { Menu } from "@/components/ui/menu"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A small set of commands on one thing, such as Share, Rename and Duplicate.&#x22;,
  &#x22;An overflow trigger on a dense list or toolbar, common actions left on screen.&#x22;,
  &#x22;A grouped set of neutral actions, with a separator marking a related run.&#x22;,
]"
  avoid="[
  { case: &#x22;The action is safety-relevant or destructive, and a reader cannot weigh a command they cannot see.&#x22;, instead: &#x22;dialog&#x22; },
  { case: &#x22;There is a single primary action, so a menu is a button in hiding.&#x22;, instead: &#x22;button&#x22; },
  { case: &#x22;You want a docked sheet of actions on a phone.&#x22;, instead: &#x22;sheet&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="menu"
  parts="[
  {
    name: &#x22;Menu.Trigger&#x22;,
    describes: &#x22;The caller's own button, merged with open behaviour and aria state.&#x22;,
    prop: &#x22;trigger&#x22;,
  },
  {
    name: &#x22;Menu.Popup&#x22;,
    describes: &#x22;The floating list, portalled below the trigger, capped with scroll.&#x22;,
    prop: &#x22;className&#x22;,
  },
  {
    name: &#x22;Menu.Item&#x22;,
    describes: &#x22;One action, muted when disabled, floored at 44pt.&#x22;,
    prop: &#x22;items&#x22;,
  },
  {
    name: &#x22;Menu.Separator&#x22;,
    describes: &#x22;A hairline above an item with separatorBefore set.&#x22;,
  },
]"
/>

<CompositionTree
  name="menu"
  tree="[
  {
    part: &#x22;Menu.Trigger&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;menu-trigger\&#x22;, one tab stop&#x22;,
  },
  {
    part: &#x22;Menu.Popup&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;menu-popup\&#x22;, role=\&#x22;menu\&#x22;, holds focus&#x22;,
    children: [
      {
        part: &#x22;Menu.Separator&#x22;,
        cardinality: &#x22;0..n&#x22;,
        note: &#x22;data-slot=\&#x22;menu-separator\&#x22;, never before the first item&#x22;,
      },
      {
        part: &#x22;Menu.Item&#x22;,
        cardinality: &#x22;1..n&#x22;,
        note: &#x22;data-slot=\&#x22;menu-item\&#x22;, role=\&#x22;menuitem\&#x22;&#x22;,
      },
    ],
  },
]"
/>

## Examples [#examples]

### Actions from a button [#actions-from-a-button]

Neutral commands fold behind an ordinary button passed as `trigger`; each
`onClick` runs straight from the list.

<ComponentPreview name="menu-actions-from-a-button" kind="example" align="start" />

### A grouped list with a separator [#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.

<ComponentPreview name="menu-with-a-separator" kind="example" align="start" />

## Content guidelines [#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.

<DoDont>
  <DoDont.Do>
    **"Share" · "Rename" · "Remove from list"** names the object.
  </DoDont.Do>

  <DoDont.Dont>
    **A red "Delete" item** borrows the status axis.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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.

<KeyboardTable
  name="menu"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus onto the trigger&#x22;,
    notes: &#x22;A single tab stop.&#x22;,
  },
  {
    keys: &#x22;Enter, Space, Arrow Down&#x22;,
    action: &#x22;Opens the menu, highlights the first item&#x22;,
    notes: &#x22;Arrow Up opens on the last item.&#x22;,
  },
  {
    keys: &#x22;Arrow Down, Arrow Up&#x22;,
    action: &#x22;Moves the highlight between items&#x22;,
    notes: &#x22;Wraps and skips disabled items.&#x22;,
  },
  {
    keys: &#x22;Enter, Space&#x22;,
    action: &#x22;Runs the highlighted item and closes the menu&#x22;,
    notes: &#x22;Focus returns to the trigger.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Closes the menu without running anything&#x22;,
    notes: &#x22;Focus returns to the trigger.&#x22;,
  },
  {
    keys: &#x22;A to Z&#x22;,
    action: &#x22;Highlights the next item starting with that letter&#x22;,
    notes: &#x22;Typeahead only.&#x22;,
  },
]"
/>

<ContrastReport component="menu" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="menu"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;Always&#x22;,
    value: &#x22;menu-trigger, menu-popup, menu-item, menu-separator&#x22;,
  },
  {
    attribute: &#x22;data-highlighted&#x22;,
    condition: &#x22;Item under the keys or pointer&#x22;,
    value: &#x22;Drives the highlight&#x22;,
  },
  {
    attribute: &#x22;data-disabled&#x22;,
    condition: &#x22;Item with disabled set&#x22;,
    value: &#x22;Drives the muted ink&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="MenuProps" />

`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 [#related]

* [Select](./select.mdx) chooses one value and keeps it shown; a menu runs and forgets.
* [Button](./button.mdx) is one action, where a safety-relevant command stays.
* [Dialog](./dialog.mdx) confirms an irreversible action; a menu item runs on one press.
* [Sheet](./sheet.mdx) docks actions to a phone's edge, large targets under the thumb.
