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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/menuIts 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.13import { 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
Selectinstead to pick a value for a form,Popoverfor a panel with fields or text, andCommandfor 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,MenuSubandMenuSeparator. 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
onOpenChangewith 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 withlabel. A menu in aMenuBargets its button fromlabel.modal: Not withlabel: theMenuBardecides how its menus hold the page.trigger: Not withlabel. A menu in aMenuBargets its button fromlabel.
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 withcontextTrigger: a context menu opens at the pointer.label: Not withcontextTrigger.labelis for a Menu insideMenuBar.modal: Not withcontextTrigger: a context menu holds the page while it is open.side: Not withcontextTrigger: a context menu opens at the pointer.trigger: Not withcontextTrigger. Use one way to open a menu.
Dropdown: MenuDropdownProps
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 withtrigger. Use one way to open a menu.label: Not withtrigger.labelis for a Menu insideMenuBar.
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 usesopenwithonOpenChange:<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 areitems(or children):items={[{ label: "Edit", onClick: edit }]}.placement: Not a prop. Ouro UI splits placement intosideandalign:<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
MenuCheckboxEntry
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;
}MenuEntry
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" };MenuGroupEntry
A menu entry for items: entries under a label.
interface MenuGroupEntry {
type: "group";
label: string;
items: readonly MenuEntry[];
}MenuItemEntry
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;
}MenuRadioEntry
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 }>;
}MenuShortcut
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 });MenuSubmenuEntry
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
- layout-classes: test:registry/ouro/components/ouro/menu/menu.test.tsx::Menu dropdown > is at least 12rem wide, and className sets layout
verify:testExists(registry/ouro/components/ouro/menu/menu.test.tsx::Menu dropdown > is at least 12rem wide, and className sets layout)