Menus

Menu

A list of actions that opens from a button, from a right-click or long press, or from a menubar, with one API for all three. Items take a shortcut (shown with Kbd), tone="destructive" and href, and there are checkbox items, radio groups, labelled groups, submenus and separators. Below 640px a dropdown is a bottom sheet; a context menu opens at the pointer at every width.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/menu#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/menu

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/menu#v1.2.13 ourostack/ouro-ui/menu-tests#v1.2.13
import { Menu } from "@/components/ouro/menu/menu";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Menu trigger={<Button variant="outline">Actions</Button>}>
  <MenuItem onClick={edit} shortcut={["mod", "e"]}>Edit</MenuItem>
  <MenuItem href="/recipes/1/print">Print</MenuItem>
  <MenuSeparator />
  <MenuItem tone="destructive" onClick={remove}>Delete</MenuItem>
</Menu>
<Menu contextTrigger={<RecipeCard recipe={recipe} />} items={[{ label: "Copy link", onClick: copy }]} />

Usage

Pass trigger for a dropdown, contextTrigger for a context menu, or label for a menu in a MenuBar. Give the entries as children or as items data. To give the trigger a tooltip too, put the Tooltip inside trigger: trigger={<Tooltip content="More actions"><Button aria-label="More">…</Button></Tooltip>}.

It stacks above the page at --ouro-layer, a z-index of 50 by default (from ouro-base.css), like Dialog and Sheet.

Use instead when

  • Use Select instead to pick a value for a form, Popover for a panel with fields or text, and Command for a searchable list of every action in the app.

Props

The props the modes share. Type: MenuProps.

children
Type: ReactNode
The entries as MenuItem, MenuCheckboxItem, MenuRadioGroup, MenuGroup, MenuSub and MenuSeparator.
className
Type: string
Layout only, applied to the menu panel: width (w-64; at least 12rem by default) and margins. The phone sheet keeps its full width. Appearance classes warn in development.
closeLabel
Type: string
The text of the item that ends the phone sheet and closes it, for screen-reader and switch users, who cannot swipe or tap the backdrop. Default "Close".
items
Type: readonly MenuEntry[]
The entries as data, in place of children: items={[{ label: "Edit", onClick: edit }, { type: "separator" }]}.
onOpenChange
Type: ((open: boolean) => void)
Called when the menu opens or closes.
open
Type: boolean
Whether the menu is open, to control it; pass onOpenChange with it.

Modes

MenuProps is a union: pass the props of exactly one mode. Mixing them is a type error.

Bar menu: MenuBarMenuProps

A menu in a MenuBar: label is its button's text.

align
Type: "start" | "center" | "end"
Default: "start"
How the menu lines up with the trigger along that side.
label (required)
Type: string
The text of the menu's button in the MenuBar: <Menu label="File">.
side
Type: "top" | "bottom" | "left" | "right"
Default: "bottom"
The side of the trigger the menu sits on; it flips when there is no room.

Not in this mode (typed never):

  • contextTrigger: Not with label. A menu in a MenuBar gets its button from label.
  • modal: Not with label: the MenuBar decides how its menus hold the page.
  • trigger: Not with label. A menu in a MenuBar gets its button from label.

Context: MenuContextProps

A context menu: right-click or long-press on contextTrigger opens it at the pointer.

contextTrigger (required)
Type: ReactElement
The area that opens the menu on right-click, or on a long press on touch screens: contextTrigger={<div>…</div>}. Its text stays selectable; only while a finger is down does it turn off text selection and the iOS callout, so a long press opens the menu. Give the same actions a visible control too: a context menu is hard to discover.

Not in this mode (typed never):

  • align: Not with contextTrigger: a context menu opens at the pointer.
  • label: Not with contextTrigger. label is for a Menu inside MenuBar.
  • modal: Not with contextTrigger: a context menu holds the page while it is open.
  • side: Not with contextTrigger: a context menu opens at the pointer.
  • trigger: Not with contextTrigger. Use one way to open a menu.

A dropdown: trigger opens it. Below 640px it is a bottom sheet.

align
Type: "start" | "center" | "end"
Default: "start"
How the menu lines up with the trigger along that side.
modal
Type: boolean
Default: true
Whether the open dropdown holds the page: it locks the page's scroll, and a click outside only closes it. modal={false} leaves the page scrolling and its controls live, and spares the browser a layout and repaint of the whole page each time the menu opens and closes: for a small menu that a list repeats on every row. The phone sheet always holds the page.
side
Type: "top" | "bottom" | "left" | "right"
Default: "bottom"
The side of the trigger the menu sits on; it flips when there is no room.
trigger (required)
Type: ReactElement
The button that opens the menu, usually a Button: trigger={<Button>Actions</Button>}.

Not in this mode (typed never):

  • contextTrigger: Not with trigger. Use one way to open a menu.
  • label: Not with trigger. label is for a Menu inside MenuBar.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • asChild: Not a prop. The trigger element is the button: trigger={<Button>Actions</Button>}.
  • isOpen: Not a prop. Ouro UI uses open with onOpenChange: <Menu open={open} onOpenChange={setOpen} trigger={…}>.
  • onClose: Not a prop. onOpenChange(false) fires on close: <Menu open={open} onOpenChange={setOpen} trigger={…}>.
  • options: Not a prop. The entries are items (or children): items={[{ label: "Edit", onClick: edit }]}.
  • placement: Not a prop. Ouro UI splits placement into side and align: <Menu side="bottom" align="start">.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: Menu merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: use the align prop.

Types

A menu entry for items: an on/off setting that stays in the menu when toggled.

interface MenuCheckboxEntry {
  type: "checkbox";
  label: string;
  checked?: boolean | undefined;
  defaultChecked?: boolean | undefined;
  onCheckedChange?: ((checked: boolean) => void) | undefined;
  shortcut?: MenuShortcut | undefined;
  disabled?: boolean | undefined;
}

One entry in a Menu's items: an item (the default type), a checkbox, a radio group, a group, a submenu or a separator.

type MenuEntry =
  | MenuItemEntry
  | MenuCheckboxEntry
  | MenuRadioEntry
  | MenuGroupEntry
  | MenuSubmenuEntry
  | { type: "separator" };

A menu entry for items: entries under a label.

interface MenuGroupEntry {
  type: "group";
  label: string;
  items: readonly MenuEntry[];
}

A menu entry for items: an action, or a link with href.

interface MenuItemEntry {
  type?: "item" | undefined;
  /** The item's text. */
  label: string;
  /** Runs when the item is chosen, by click, tap or Enter. The menu then closes. */
  onClick?: (() => void) | undefined;
  /** Makes the item a link, through `OuroProvider`'s router link for an internal path. */
  href?: string | undefined;
  /** The keys of the item's shortcut, shown at its end: `["mod", "e"]`. It does not bind them. */
  shortcut?: MenuShortcut | undefined;
  /** `"destructive"` for an action that deletes or cannot be undone. */
  tone?: "destructive" | undefined;
  /** Shown, but cannot be chosen. */
  disabled?: boolean | undefined;
}

A menu entry for items: one choice of several, as radio items under an optional label.

interface MenuRadioEntry {
  type: "radio";
  /** Names the group: "Sort by". */
  label?: string | undefined;
  value?: string | undefined;
  defaultValue?: string | undefined;
  onValueChange?: ((value: string) => void) | undefined;
  options: ReadonlyArray<{ value: string; label: string; shortcut?: MenuShortcut | undefined; disabled?: boolean | undefined }>;
}

Keys for a shortcut, as Kbd takes them: ["mod", "e"] shows ⌘E on Apple platforms and Ctrl+E elsewhere.

type MenuShortcut = readonly string[] | (string & { 'Use an array: shortcut={["mod", "k"]}': never });

A menu entry for items: an item that opens a nested menu.

interface MenuSubmenuEntry {
  type: "submenu";
  label: string;
  items: readonly MenuEntry[];
  disabled?: boolean | undefined;
  /** The text of the item that goes back from the submenu in the phone sheet. Default "Back". */
  backLabel?: string | undefined;
}

Claim sources