Photon Design System

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:

PartRoleCloses on activate?
Menu.ItemRun an action (onClick)yes (default)
Menu.LinkItemNavigate (href)yes
Menu.SubmenuTriggerOpen a nested menu—
Menu.CheckboxItemIndependent toggleno (default)
Menu.RadioItemExclusive choice in a RadioGroupno (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 (default false) — initial open state when uncontrolled
  • onOpenChange — (open, details) => void — fires when the menu opens or closes
  • modal — boolean (default true) — lock page scroll and block outside interaction while open
  • loopFocus — boolean (default true) — wrap arrow-key focus at the ends
  • orientation — horizontal · vertical (default vertical) — arrow-key axis
  • disabled — boolean (default false) — ignore all interaction
  • closeParentOnEsc — boolean (default false) — in a submenu, Escape closes the whole menu

Menu.Trigger (<button>)

  • openOnHover — boolean — also open on hover, not just click
  • delay — number (default 100) — hover-open delay in ms; requires openOnHover
  • disabled — boolean (default false)

Menu.Positioner (<div>)

  • side — top · right · bottom · left (default bottom) — which side of the trigger to place the popup
  • align — start · center · end (default center) — alignment along that side
  • sideOffset — number (default 6) — gap between trigger and popup
  • alignOffset — number (default 0) — 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 (default default)
  • onClick — (event) => void | Promise<unknown> — return a promise to lock the menu open (with a row spinner) until it settles; resolve closes when closeOnClick is true, reject stays open
  • loading — boolean (default false) — declarative loading (same spinner as an in-flight promise)
  • closeOnClick — boolean (default true) — close the menu when clicked (or when an async onClick resolves)
  • disabled — boolean (default false)
  • label — string — overrides the text used for typeahead matching

Menu.LinkItem (<a role="menuitem">)

  • color — default · destructive (default default)
  • href — string — destination
  • Same interaction props as Menu.Item (closeOnClick, disabled, label)

Menu.SubmenuTrigger (<div role="menuitem">)

  • Opens the nested Menu.SubmenuRoot popup; style matches Menu.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 (default false) — initial state when uncontrolled
  • onCheckedChange — (checked, details) => void
  • closeOnClick — boolean (default false)
  • disabled — boolean (default false)

Menu.RadioGroup (<div role="group">)

  • value — any — controlled selected value
  • defaultValue — any — initial value when uncontrolled
  • onValueChange — (value, details) => void
  • disabled — boolean (default false)

Menu.RadioItem (<div role="menuitemradio">)

  • value — any (required) — value set on the group when this item is chosen
  • closeOnClick — boolean (default false)
  • disabled — boolean (default false)

Accessibility

  • Semantics — the trigger is a <button> with aria-haspopup="menu" and aria-expanded; the popup is role="menu". Items are role="menuitem", menuitemcheckbox, or menuitemradio with aria-checked, and a GroupLabel labels its Group / RadioGroup via aria-labelledby.
  • Focus — opening moves focus into the popup and traps it while modal; closing returns focus to the trigger. The highlighted item carries data-highlighted for styling.

Keyboard:

  • Enter / Space / Arrow Down / Arrow Up on the trigger open the menu
  • Arrow Up / Arrow Down move the highlight (looping by default)
  • Arrow Right opens a submenu; Arrow Left closes it
  • Enter / Space activate the highlighted item (after its confirm blink)
  • Typeahead: typing characters jumps to the matching item
  • Home / End jump to the first / last item
  • Escape closes the menu (the submenu first, then its parent); Tab closes it

Best practices

  • Use a menu for a list of actions, not for choosing a setting that should stay visible — that’s RadioGroup or Select.
  • 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 / RadioItem when a toggle belongs beside its actions; they hold their state and stay open so a run of changes is one trip.