Menu
Offer contextual commands or compact settings with real effects, clear state and predictable dismissal.
Choose commands rather than navigation or a form
Use Menu for a compact list of commands or settings associated with a visible trigger. Use Select for a form value, a navigation structure for destinations, and Dialog for a consequential task needing explanation or input. A menu can start such a task; it should not contain an entire form.
Use concise action labels and show unavailable commands only when their presence helps the user understand the task. Explain why an action is unavailable nearby. Commands, checkbox settings, radio alternatives and submenu openers have distinct semantics.
Anatomy and specifications
| Part | Actual implementation |
|---|---|
| Menu / MenuTrigger | State root and default native button; render={<Button />} composes a named 44px trigger. |
| MenuPortal / Positioner | Portal normally to body; installed collision/anchor positioning; dropdown z-index. Portal theme scope must still resolve shared tokens. |
| MenuPopup | Popover surface, 2px border, radius-md, 4px padding; border treatment without a second decorative shadow. Minimum width min(192px, viewport−32px), maximum min(448px, viewport−32px). |
| Scroll boundary | Max height min(installed available-height, 80dvh); long content scrolls and labels wrap. Avoid clipped final commands. |
| MenuItem / SubmenuTrigger | At least 44px high; text-base, 6px/8px padding, radius-sm; highlighted outline and disabled visual state. |
| Group / GroupLabel / Separator | Named command groups, text-sm supporting headings and 1px separators; not activation targets. |
| Checkbox / Radio indicators | 16px reserved visible-state slot; checked state has a mark as well as aria-checked. keepMounted avoids alignment movement. |
Material vertical menus, groups and contextual placement
The November 2025 reference recommends expressive vertical menus while retaining baseline menus. Standard surface colors are lower emphasis; vibrant tertiary roles, round groups, submenu shape changes and richer slots are native presentation choices. This library’s installed Menu has its own token styling and command/selection parts; it does not acquire native shape morphing or a new density API by looking similar.
| Reference point | Web choice and verification boundary |
|---|---|
| Anatomy and groups | Menu item text is central; leading/trailing decorative icons, supporting labels, badges and shortcuts must keep one action per item. Native expressive gaps are limited, disallowed in scrolling menus and explicitly unavailable on web; use meaningful MenuGroup/MenuLabel/MenuSeparator rather than fake focusable gaps. |
| Native measurements | Baseline reference width 112–280dp,48dp rows,4dp corners,12dp padding/gaps,24dp icons and 1dp dividers are not the local CSS contract. Native slot advice uses 48dp targets; keep actual local geometry, full labels and independent focus tested at narrow/enlarged sizes. |
| Command and selection roles | Transient commands differ from persistent toolbar actions, route navigation and text-entry selection. MenuCheckboxItem/MenuRadioItem keep their installed checked roles and real value effects. Conditional unavailable actions remain explainable; source permits disabled item focus, so use this pin’s explicit focusableWhenDisabled choice rather than assuming every disabled control is skipped. |
| Context and filtering | Object actions need a discoverable trigger in addition to secondary click or long-press conventions. A typed selection task belongs to the actual Combobox/Autocomplete/Select composition; do not add nested buttons/switches or arbitrary form inputs inside one command item. Shortcuts shown as hints must actually exist. |
| Collision and adaptation | Position relative to the real trigger and flip/shift within available space; submenus should not cover their parent item. Compact long/complex action sets may use the documented Drawer sheet alternative; menus and modal sheets retain different focus/dismissal contracts. No automatic native sheet swap or predictive back is installed. |
| Keyboard and lifecycle | The reference’s Tab prose differs from its arrow/typeahead table. Follow actual first-item focus, Up/Down, typeahead, submenu-direction keys, Enter/Space, Escape/outside close and final focus for this installed Menu. Disabled actions reject activation, separators do not focus, and checked settings must survive dismissal independently of popup state. |
| Scrolling and motion | When content exceeds the popup, retain reachable items and a clear scroll affordance; do not create unsupported expressive gaps in a scroller. Local enter/exit/reduced-motion behavior is separate from native ripple, group morphs and density 0 to−3. Keep trigger identity and useful outcomes stable after an action closes or disables its opener. |
Working commands, settings and recovery
Local reading record
English-only local notes are visible.
Local copies: 0.
No local command run. Reading layout: compact. Menus do not prove authority or permission.
'use client';
import { useRef, useState } from 'react';
import { Button, Menu, MenuCheckboxItem, MenuCheckboxItemIndicator, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuPortal, MenuPositioner, MenuRadioGroup, MenuRadioItem, MenuRadioItemIndicator, MenuSeparator, MenuSubmenuRoot, MenuSubmenuTrigger, MenuTrigger } from '@mobtranslate/ui';
export function MenuCommandsExample() {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('Local reading record');
const [copies, setCopies] = useState(0);
const [notes, setNotes] = useState(true);
const [layout, setLayout] = useState('compact');
const [pending, setPending] = useState(false);
const [result, setResult] = useState('No local command run.');
const context = useRef<HTMLDivElement>(null);
const submenu = useRef<HTMLElement>(null);
function resolve(outcome: 'complete' | 'fail' | 'cancel') {
if (!pending) return;
if (outcome === 'complete') { setTitle('Updated local reading record'); setResult('Local title changed. No server write occurred.'); }
if (outcome === 'fail') setResult('Local rename failed. The original title remains; retry is available.');
if (outcome === 'cancel') setResult('Local rename cancelled. The original title remains.');
setPending(false); context.current?.focus();
}
return <div data-menu-lab className="space-y-4">
<div ref={context} tabIndex={-1} role="region" aria-label="Local command context" data-menu-record style={{lineHeight:layout === 'compact' ? 1.5 : 1.75, padding:layout === 'compact' ? 8 : 16}}>
<p>{title}</p>{notes && <p data-command-notes>English-only local notes are visible.</p>}<p data-command-copies>Local copies: {copies}.</p>
</div>
<Menu open={open} onOpenChange={setOpen} disabled={pending}>
<MenuTrigger data-command-trigger render={<Button variant="outline" disabled={pending}>Local record commands</Button>} />
<MenuPortal><MenuPositioner sideOffset={8} align="start"><MenuPopup aria-label="Local record commands" finalFocus={context}>
<MenuGroup><MenuGroupLabel>Local commands</MenuGroupLabel>
<MenuItem onClick={() => { setCopies(count => count+1); setResult('Local copy added. No source was duplicated remotely.'); }}>Duplicate local record</MenuItem>
<MenuItem onClick={() => { setPending(true); setResult('Local rename pending. Choose an explicit outcome below.'); }}>Rename local record…</MenuItem>
<MenuItem disabled onClick={() => setResult('Unexpected unavailable command activation.')}>Publish to server (unavailable)</MenuItem>
</MenuGroup><MenuSeparator />
<MenuCheckboxItem checked={notes} onCheckedChange={setNotes} className={state => state.checked ? 'local-notes-checked' : undefined}><MenuCheckboxItemIndicator keepMounted aria-hidden="true">✓</MenuCheckboxItemIndicator>Show local notes</MenuCheckboxItem>
<MenuSubmenuRoot closeParentOnEsc={false} onOpenChangeComplete={childOpen => { if (!childOpen && open) submenu.current?.focus(); }}><MenuSubmenuTrigger ref={submenu} label="Reading layout">Reading layout <span aria-hidden="true">›</span></MenuSubmenuTrigger>
<MenuPortal><MenuPositioner side="right" align="start" sideOffset={4}><MenuPopup aria-label="Reading layout choices" finalFocus={submenu}>
<MenuRadioGroup value={layout} onValueChange={setLayout} aria-label="Local reading density">
<MenuRadioItem value="compact"><MenuRadioItemIndicator keepMounted aria-hidden="true">●</MenuRadioItemIndicator>Compact layout</MenuRadioItem>
<MenuRadioItem value="comfortable"><MenuRadioItemIndicator keepMounted aria-hidden="true">●</MenuRadioItemIndicator>Comfortable layout</MenuRadioItem>
</MenuRadioGroup>
</MenuPopup></MenuPositioner></MenuPortal>
</MenuSubmenuRoot>
</MenuPopup></MenuPositioner></MenuPortal>
</Menu>
{pending && <div className="flex flex-wrap gap-3"><Button type="button" onClick={() => resolve('complete')}>Complete local rename</Button><Button type="button" variant="outline" onClick={() => resolve('fail')}>Fail local rename</Button><Button type="button" variant="outline" onClick={() => resolve('cancel')}>Cancel local rename</Button></div>}
<Button type="button" variant="outline" disabled={pending} onClick={() => { setTitle('Local reading record'); setCopies(0); setResult('Restored the local record and cleared local copies.'); }}>Restore local command context</Button>
<p role="status">{result} Reading layout: {layout}. Menus do not prove authority or permission.</p>
</div>;
}
Root and trigger API
| Prop | Installed rc contract |
|---|---|
| open / defaultOpen / onOpenChange | Controlled or initial open state (false default), callback with change details. Keep one state owner; details.cancel() can reject a transition. |
| modal | true default; open-menu interaction boundary and page scroll behavior. false permits outside interaction. |
| disabled / loopFocus / highlightItemOnHover | false / true / true defaults. Actual disabled-item focus behavior must be tested for this version. |
| orientation | vertical default for popup item navigation; horizontal alternative does not replace a Menubar container. |
| closeParentOnEsc | true default for nested behavior; the example sets false on its submenu to allow child-only dismissal. |
| onOpenChangeComplete / actionsRef | Completion callback after transitions; installed typed actions expose unmount. Do not assume a close method from a different version. |
| triggerId / defaultTriggerId / handle | Advanced association for controlled, initial or detached triggers. Follow installed types and verify the actual active anchor; the examples use attached triggers. |
| MenuTrigger render / disabled / native props | Installed trigger composition. One native button, meaningful label, expanded/popup relationship and non-submit default. |
| MenuTrigger openOnHover / delay / closeDelay | Optional pointer behavior; hover must not be the sole access path. |
| className / style / render / refs | Installed part contracts; styled wrappers retain string-or-state class callbacks. Default item/submenu refs target their actual elements. Custom renders must preserve events, roles, names and refs. |
Parts and selection API
| Export / prop | Contract |
|---|---|
| MenuItem disabled / label / onClick / closeOnClick | Command handler; label overrides text navigation label; closeOnClick defaults true. Disabled suppresses activation. |
| MenuCheckboxItem checked / defaultChecked / onCheckedChange | Controlled/initial boolean; closeOnClick defaults false. Setting is not native form checkbox serialization. |
| MenuRadioGroup value / defaultValue / onValueChange | Selected alternative; caller owns domain/type and allowed values. |
| MenuRadioItem value / disabled / label / closeOnClick | Actual alternative value; closeOnClick defaults false. Use a separate group for each independent question. |
| MenuCheckboxItemIndicator / MenuRadioItemIndicator | Checked-state primitive, keepMounted false default; supply decorative mark and aria-hidden when the item already conveys state. |
| MenuSubmenuRoot / MenuSubmenuTrigger | Installed child state and opener; trigger has label/disabled/openOnHover/delay/closeDelay. Nested portal/positioner/popup supplies its surface. |
| MenuGroup / MenuGroupLabel / MenuSeparator | Grouping and presentation; preserve label relationship and separator semantics. |
| MenuPositioner side / align / sideOffset / alignOffset / collision options | Installed placement props; requested side can flip to fit available space. Read rendered geometry in small viewports. |
| MenuPopup finalFocus | Default restoration, explicit ref, false, or interaction-dependent callback. Use an enabled surviving task target after a command removes/disables its trigger. |
| MenuPortal container / keepMounted | Installed portal location/mounting strategy. A custom container changes theme/layout context and needs verification. |
| MenuArrow / MenuBackdrop | Optional installed positioning/backdrop parts. Arrow content/shape and backdrop visual styling remain caller-owned; default menu already has its interaction boundary. |
States and meaningful command outcomes
| State | Behavior |
|---|---|
| Closed / open | Trigger exposes availability and expanded state; opening presents a named command list. |
| Highlighted / disabled | Visible focus cue independent of color fill. Unavailable items expose disabled semantics and cannot run. |
| Checked / radio-selected | Reflect actual setting; visible marks and programmatic checked state agree. |
| Submenu open | Parent indicates expansion; child choices have their own name and a return path. |
| Requested / pending | Close the command menu, preserve original task data, guard duplicate requests and provide stable outcome controls. |
| Failure / cancel | Retain task data, report the boundary and offer retry or independent recovery. |
| Completed / restored | Change actual local content or confirmed remote data; distinguish that result from merely closing the menu. |
Keyboard and focus contract
- Use the installed keyboard navigation, activation and dismissal handlers; do not replace them with a click-only div. The trigger, focused items, submenu and task context each have a distinct role.
- The child popup uses an explicit finalFocus ref and restores its opener at close completion while the parent remains open; this covers pointer-choice then Escape in the installed version. The lab uses real duplication, a pending rename with explicit failure/cancel/completion, a checkbox that hides actual notes, and radios that change reading spacing. Type navigation uses meaningful labels.
- A submenu may return to its opener while the parent remains open. RTL context requires DirectionProvider as well as DOM direction; verify the actual mirrored navigation keys.
- When an outcome removes its initiating control, focus a surviving task context or recovery action. Do not manually focus a detached element or report that a menu focus test proves spoken announcements.
Failures and recovery
- Do not use inert Edit/Save/Delete labels as evidence of a functional menu. A command must have an observable task outcome.
- An ellipsis signals that a further task follows; explain and verify that task. Use confirmation/undo for meaningful removal rather than relying on a red label. Close an open menu or guard its command handlers when the task becomes unavailable.
- Do not let an overflowing popup hide the last action; preserve bounded scrolling and wrap long labels. A portal can escape a locally scoped theme.
- Popup, checkbox and radio close defaults differ. Closing the menu is not equivalent to changing the selected value or saving it.
- Current upstream docs describe newer packages/APIs. This wrapper uses @base-ui-components/react 1.0.0-rc.0; installed declarations/runtime and browser behavior govern this guide.
Verification and release scope
- Verify the actual command effect, requested/retained state, recovery and focus after activation. A closed menu or click callback is not a save receipt.
- Check trigger naming/expanded state, item roles, group names, checked states, disabled enforcement, keyboard entry/exit, type navigation, submenu return and focus restoration. Inspect the exact installed behavior rather than assuming an upstream convention.
- Check both themes, readable text/focus cues, long labels, bounded scrolling, 320px reflow, reduced motion, forced colors, zoom and the actual touch device. Context long press is not proven by mouse tests.
- Use the checked example sources and the menu-family browser suite, then a named screen-reader/browser pair for spoken output. These local English-only examples prove no server write, permission, source authority or qualified review.