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.
A press that ends the menu (a row that closes on click, or a link) blinks
its row first, as on macOS: the wash goes off for MENU_BLINK_MS (90ms),
comes back for as long, and then the press goes through. The action runs,
the link navigates, the menu closes. It confirms the press landed on the
right row, where most web menus close on the same frame. Rows that leave
the menu open act at once, and so does a modified click on a link (a new
tab). The menu holds through the blink: no dismissal, no second press.
ContextMenu and Menubar rows blink the same way. In tests, assert the
action with waitFor, or advance fake timers past MENU_BLINK_MS * 2.
<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 (after its confirm blink)- 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.