Menu
A dropdown list of actions opened from a trigger. Built on Base UI Menu, with
keyboard navigation, grouped items, submenus, and stateful checkbox/radio
items. For a right-click menu use ContextMenu; for a set of choices that stays
on screen use RadioGroup.
Import
import { Menu } from "@photon-ai/pho-ui/components/menu";
Items
Every row shares the same MENU_ITEM surface (secondary at rest → primary +
ghost wash when highlighted). Pick the part that matches the job:
| Part | Role | Closes on activate? |
|---|---|---|
Menu.Item |
Run an action (onClick) |
yes (default) |
Menu.LinkItem |
Navigate (href) |
yes |
Menu.SubmenuTrigger |
Open a nested menu | — |
Menu.CheckboxItem |
Independent toggle | no (default) |
Menu.RadioItem |
Exclusive choice in a RadioGroup |
no (default) |
Destructive actions use color="destructive" on Item / LinkItem — icons
inherit the row ink via text-current.
Menu.Item supports async onClick the same way as Dialog.Action: return a
promise to keep the menu open with a spinner and lock dismissal while pending.
On resolve the menu closes when closeOnClick is true (the default); on reject
it stays open. Use useMenuPending() to read the in-flight state.
<Menu.Popup>
<Menu.Item>…</Menu.Item>
<Menu.Item onClick={() => fetch("/api").then(/* … */)}>
Async Promise Item
</Menu.Item>
<Menu.LinkItem href="/docs">…</Menu.LinkItem>
<Menu.SubmenuTrigger>…</Menu.SubmenuTrigger>
<Menu.CheckboxItem>…</Menu.CheckboxItem>
<Menu.RadioGroup>
<Menu.RadioItem value="a">…</Menu.RadioItem>
</Menu.RadioGroup>
<Menu.Item color="destructive">Delete</Menu.Item>
</Menu.Popup>
Basic
Compose Items with SubmenuRoot / Separator. Menu.Trigger takes render
to borrow any control’s look — here an outlined Button.
<Menu.Root>
<Menu.Trigger
render={
<Button variant="outlined" suffix={CaretDown}>
Options
</Button>
}
/>
<Menu.Portal>
<Menu.Positioner align="start">
<Menu.Popup>
<Menu.Item>
<PencilSimple />
Edit
</Menu.Item>
<Menu.SubmenuRoot>
<Menu.SubmenuTrigger>
Share
<CaretRight className="ml-auto" />
</Menu.SubmenuTrigger>
<Menu.Portal>…</Menu.Portal>
</Menu.SubmenuRoot>
<Menu.Separator />
<Menu.Item color="destructive">Delete</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
Checkbox and radio items
For settings that live inside the menu, use Menu.CheckboxItem for independent
toggles and Menu.RadioGroup + Menu.RadioItem for a single choice. Each pairs
with an Indicator that renders only when selected; both are stateful and stay
open on click (closeOnClick defaults to false), so a run of toggles doesn’t
dismiss the menu.
<Menu.Group>
<Menu.GroupLabel>Editor</Menu.GroupLabel>
<Menu.CheckboxItem checked={wrap} onCheckedChange={setWrap}>
<Menu.CheckboxItemIndicator>
<Check />
</Menu.CheckboxItemIndicator>
Wrap lines
</Menu.CheckboxItem>
</Menu.Group>
<Menu.Separator />
<Menu.RadioGroup value={density} onValueChange={setDensity}>
<Menu.GroupLabel>Density</Menu.GroupLabel>
<Menu.RadioItem value="comfortable">
<Menu.RadioItemIndicator>
<Check />
</Menu.RadioItemIndicator>
Comfortable
</Menu.RadioItem>
</Menu.RadioGroup>
Anatomy
- Menu.Root / .Trigger — open state and the control that opens it
- Menu.Portal → Positioner → Popup — the floating panel
- Menu.Item — action row · Menu.LinkItem — navigation row
- Menu.SubmenuRoot + .SubmenuTrigger — nested submenu
- Menu.CheckboxItem / .RadioItem (+
Indicator) — stateful rows - Menu.RadioGroup — owns the selected value for its
RadioItems - Menu.Group / .GroupLabel / .Separator — organize the list
Props
Every part also accepts className and render — Base UI’s prop for swapping
the element it renders (e.g. a Button for the trigger).
Menu.Root (holds state; renders no element)
- open —
boolean— controlled open state - defaultOpen —
boolean(defaultfalse) — initial open state when uncontrolled - onOpenChange —
(open, details) => void— fires when the menu opens or closes - modal —
boolean(defaulttrue) — lock page scroll and block outside interaction while open - loopFocus —
boolean(defaulttrue) — wrap arrow-key focus at the ends - orientation —
horizontal·vertical(defaultvertical) — arrow-key axis - disabled —
boolean(defaultfalse) — ignore all interaction - closeParentOnEsc —
boolean(defaultfalse) — in a submenu, Escape closes the whole menu
Menu.Trigger (<button>)
- openOnHover —
boolean— also open on hover, not just click - delay —
number(default100) — hover-open delay in ms; requiresopenOnHover - disabled —
boolean(defaultfalse)
Menu.Positioner (<div>)
- side —
top·right·bottom·left(defaultbottom) — which side of the trigger to place the popup - align —
start·center·end(defaultcenter) — alignment along that side - sideOffset —
number(default6) — gap between trigger and popup - alignOffset —
number(default0) — shift along the alignment axis - anchor —
Element · ref · () => Element— element to position against; defaults to the trigger
Menu.Item (<div role="menuitem">)
- color —
default·destructive(defaultdefault) - onClick —
(event) => void | Promise<unknown>— return a promise to lock the menu open (with a row spinner) until it settles; resolve closes whencloseOnClickis true, reject stays open - loading —
boolean(defaultfalse) — declarative loading (same spinner as an in-flight promise) - closeOnClick —
boolean(defaulttrue) — close the menu when clicked (or when an asynconClickresolves) - disabled —
boolean(defaultfalse) - label —
string— overrides the text used for typeahead matching
Menu.LinkItem (<a role="menuitem">)
- color —
default·destructive(defaultdefault) - href —
string— destination - Same interaction props as
Menu.Item(closeOnClick,disabled,label)
Menu.SubmenuTrigger (<div role="menuitem">)
- Opens the nested
Menu.SubmenuRootpopup; style matchesMenu.Item - Put a trailing chevron (
ml-auto) so the row reads as a submenu
Menu.CheckboxItem (<div role="menuitemcheckbox">)
- checked —
boolean— controlled checked state - defaultChecked —
boolean(defaultfalse) — initial state when uncontrolled - onCheckedChange —
(checked, details) => void - closeOnClick —
boolean(defaultfalse) - disabled —
boolean(defaultfalse)
Menu.RadioGroup (<div role="group">)
- value —
any— controlled selected value - defaultValue —
any— initial value when uncontrolled - onValueChange —
(value, details) => void - disabled —
boolean(defaultfalse)
Menu.RadioItem (<div role="menuitemradio">)
- value —
any(required) — value set on the group when this item is chosen - closeOnClick —
boolean(defaultfalse) - disabled —
boolean(defaultfalse)
Accessibility
- Semantics — the trigger is a
<button>witharia-haspopup="menu"andaria-expanded; the popup isrole="menu". Items arerole="menuitem",menuitemcheckbox, ormenuitemradiowitharia-checked, and aGroupLabellabels itsGroup/RadioGroupviaaria-labelledby. - Focus — opening moves focus into the popup and traps it while
modal; closing returns focus to the trigger. The highlighted item carriesdata-highlightedfor styling.
Keyboard:
Enter/Space/Arrow Down/Arrow Upon the trigger open the menuArrow Up/Arrow Downmove the highlight (looping by default)Arrow Rightopens a submenu;Arrow Leftcloses itEnter/Spaceactivate the highlighted item- Typeahead: typing characters jumps to the matching item
Home/Endjump to the first / last itemEscapecloses the menu (the submenu first, then its parent);Tabcloses it
Best practices
- Use a menu for a list of actions, not for choosing a setting that should stay
visible — that’s
RadioGrouporSelect. - Keep menus shallow; one level of submenu at most. Deep nesting is hard to navigate with a pointer.
- Order items by frequency, separate destructive actions, and mark them
color="destructive". - Reach for
CheckboxItem/RadioItemwhen a toggle belongs beside its actions; they hold their state and stay open so a run of changes is one trip.