Design system
Docs
Desktop

Menu

components/menu.tsx: the one menu surface for pane headers, sidebar filters, model chip, context menus, and the command palette

Import
import {
  CommandMenu,
  ContextMenu,
  ContextMenuItem,
  ContextMenuSeparator,
  Menu,
  MenuCheckboxItem,
  MenuGroup,
  MenuItem,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuSubmenu,
  MenuSwitchItem,
} from "../components/menu.tsx";

File: packages/desktop/src/renderer/src/components/menu.tsx, over @nyte-ai/ui/menu and @nyte-ai/ui/context-menu. From its header:

The desktop's menu, over Base UI's Menu: positioning, focus, typeahead, dismissal, and roving tab index are the library's job. This file decides geometry and colour once, so the pane header, the sidebar filter, and the model chip all open the same surface.

Every item reserves the leading icon slot and the trailing meta column whether or not it uses them, so labels start and end on the same edges in every menu.

Anatomy

<Menu label="Session" trigger={<button>…</button>}>
  <MenuItem icon="pencil" meta={<Kbd keys={["⌘", "R"]} plain />} onSelect={rename}>Rename</MenuItem>
  <MenuSeparator />
  <MenuRadioGroup value={effort} onValueChange={setEffort}>
    <MenuRadioItem value="low">Low</MenuRadioItem>
  </MenuRadioGroup>
  <MenuCheckboxItem checked={showAll} onCheckedChange={setShowAll}>Show all turns</MenuCheckboxItem>
  <MenuSwitchItem tone="green" checked={wrap} onCheckedChange={setWrap}>Word wrap</MenuSwitchItem>
  <MenuSubmenu label="Move to" value="Inbox">
    <MenuItem onSelect={…}>Archive</MenuItem>
  </MenuSubmenu>
  <MenuGroup label="Filters" action={{ label: "Reset", onSelect: reset }}>…</MenuGroup>
  <MenuItem danger onSelect={remove}>Delete</MenuItem>
</Menu>

Every item body is three columns: leading (icon slot, 14px or 12px when size="small"), label, and meta. layout="plain" drops the leading slot for lists that never carry icons (thinking levels in the model picker).

PropTypeDefaultDescription
labelstringRequired. aria-label on the popup.
idstringPopup DOM id, "so a combobox can point aria-controls at it."
triggerReactElementRequired. "Base UI merges the trigger props into it."
sideMenu.Positioner.Props["side"]"bottom"
alignMenu.Positioner.Props["align"]"start"
anchorMenu.Positioner.Props["anchor"]Position against something other than the trigger.
sideOffsetnumber4
alignOffsetnumber
collisionPaddingnumber8
popupStyleStyleXStyles"Popup overrides such as a wider surface for long lists."
openbooleanControlled open.
modalbooleanForwarded to Root.
loopFocusbooleanForwarded to Root.
highlightItemOnHoverbooleanForwarded to Root.
onOpenChange(open: boolean) => void
onOpenChangeComplete(open: boolean) => voidAfter the exit transition; the model picker clears its search here.

Fixed decisions: positionMethod="fixed", collision avoidance { side: "flip", align: "shift", fallbackAxisSide: "none" }, popup width min(max(control.menuWidth, --anchor-width), --available-width) and max 320px, overscroll-behavior: contain, and a 2px slide + 0.98 scale enter/exit keyed on data-side.

PropTypeDefaultDescription
onSelect() => voidRequired. Base UI onClick.
iconIconNameLeading glyph.
leadingReactNodeReplaces icon (a StatusDot, a provider mark).
metaReactNode"Right column: a shortcut, a count, a provider name."
size"medium" | "small" | "compact""medium"
layout"menu" | "plain""menu"plain removes the icon column.
disabledbooleanfalse
dangerbooleanfalseDanger text color and highlight.
closeOnClickbooleantrue
textValuestringTypeahead label when children are not plain text.
selectedbooleanfalseSets data-nyte-selected.
itemStyleStyleXStylesPer-item override (the palette's result rows).
id, onPointerMoveFor combobox aria-activedescendant wiring.

ContextMenuItem takes the same props over ContextMenu.Item.

MenuRadioGroup is Menu.RadioGroup unchanged. MenuRadioItem renders Menu.RadioItem with a check indicator in the leading slot.

PropTypeDefaultDescription
valuestringRequired.
labelstring"Typeahead text when the body is more than a label."
onFocus() => void"Base UI focuses the highlighted item, so this is the highlight signal."
closeOnClickbooleanBase UI default for radio items is false; sidebar filters keep it.
disabled, id, body props

Both render Menu.CheckboxItem (role="menuitemcheckbox", aria-checked). The checkbox item shows a check indicator in the leading slot; the switch item draws a track in the meta column and has no meta prop.

PropTypeComponent
checkedbooleanboth
onCheckedChange(checked: boolean) => voidboth
disabledbooleanboth
closeOnClickbooleancheckbox only
tone"accent" | "green"switch only

Renders Menu.SubmenuRoot › SubmenuTrigger › Portal › Positioner › Popup with a trailing chevron and an optional current-value readout.

PropTypeDescription
labelstringRequired. Trigger text and popup aria-label.
valueReactNodeCurrent value shown before the chevron.
disabledboolean
alignMenuAlign
popupStyleStyleXStyles
onOpenChangeComplete(open) => void

Submenus sit on layer.submenu, one step above layer.menu.

Menu.Group with a GroupLabel and an optional trailing action rendered as a Menu.Item.

PropType
labelReactNode
action{ label: string; onSelect: () => void }

inset starts the rule after the icon column.

ContextMenu

ContextMenu.Root › Trigger(render) › Portal › Positioner › Popup with the same popup styles as Menu. Props: label, trigger, children. Opens on right-click and on the context-menu key; Base UI positions it at the pointer.

CommandMenu

"A modal Menu surface anchored to the viewport rather than its rail trigger." The base of the search palette.

PropTypeDescription
labelstringRequired.
triggerReactElementRequired; gets a generated id.
openbooleanRequired, controlled.
onOpenChange(open: boolean) => voidRequired.
popupRefRef<HTMLDivElement>

Fixed decisions: modal, a Menu.Backdrop, a viewport anchor, side="bottom", align="center", collision { side: "shift", align: "shift" } with 16px padding, and floatingSurfaceStyles.modalPopup.

Accessibility

Base UI's menu model applies throughout; the wrapper adds labels and never intercepts keys:

  • Arrow keys move highlight (wrapping), Home/End jump, typeahead matches textValue/label or the item text, Enter/Space activate, Escape closes and returns focus to the trigger.
  • Arrow Right opens a submenu, Arrow Left closes it; submenus also open on hover.
  • danger is color plus position (last, after a separator); it is not conveyed by ARIA, so the label text must say what it does ("Delete", "Remove from sidebar").
  • Every popup has an aria-label because label is required.
  • Menu items use focus.ringInset.