Propel

Menu

A popup list of actions or options opened from a trigger.

Rows take variant for their look — neutral (the default), accent for the primary action, and danger for a destructive one.

MenuContent is as wide as its rows. sizing="auto" (the default) floors that at 204px and caps it at 400px; sizing="anchor" matches the trigger. See Sizing below.

Show code
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import {
  CopyOutline,
  NewTabArrowOutline,
  EditOutline,
  DeleteOutline,
} from "@makeplane/propel/icons";

export default function BasicDemo() {
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Actions"
      />
      <MenuContent>
        <MenuItem variant="accent" icon={<Icon icon={EditOutline} />} label="Edit" />
        <MenuItem icon={<Icon icon={CopyOutline} tint="secondary" />} label="Make a copy" />
        <MenuItem
          icon={<Icon icon={NewTabArrowOutline} tint="secondary" />}
          label="Open in new tab"
        />
        <MenuSeparator />
        <MenuItem variant="danger" icon={<Icon icon={DeleteOutline} />} label="Delete" />
      </MenuContent>
    </Menu>
  );
}

Installation

import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
} from "@makeplane/propel/components/menu";

Usage

import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import {
  CopyOutline,
  NewTabArrowOutline,
  EditOutline,
  DeleteOutline,
} from "@makeplane/propel/icons";

export default function BasicDemo() {
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Actions"
      />
      <MenuContent>
        <MenuItem variant="accent" icon={<Icon icon={EditOutline} />} label="Edit" />
        <MenuItem icon={<Icon icon={CopyOutline} tint="secondary" />} label="Make a copy" />
        <MenuItem
          icon={<Icon icon={NewTabArrowOutline} tint="secondary" />}
          label="Open in new tab"
        />
        <MenuSeparator />
        <MenuItem variant="danger" icon={<Icon icon={DeleteOutline} />} label="Delete" />
      </MenuContent>
    </Menu>
  );
}

Examples

Checkbox items

Multi-select rows built from MenuCheckboxItem. Each row keeps its own checked state and the menu stays open on click. MenuContent’s search prop pins a sticky MenuSearch field above the list; typing filters the rows. The menu focuses the field when it opens; Escape closes the menu from inside it, ArrowDown and ArrowUp move focus into the list, and the query clears when the menu closes — so a reopened menu is unfiltered (resetOnClose={false} keeps a controlled query on purpose).

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Menu,
  MenuCheckboxItem,
  MenuContent,
  MenuSearch,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import * as React from "react";

const PROPERTIES = [
  { key: "assignee", label: "Assignee" },
  { key: "priority", label: "Priority" },
  { key: "due_date", label: "Due date" },
  { key: "labels", label: "Labels" },
  { key: "start_date", label: "Start date" },
] as const;

export default function CheckboxItemsDemo() {
  const [checked, setChecked] = React.useState<Record<string, boolean>>({
    assignee: true,
    priority: true,
  });
  const [query, setQuery] = React.useState("");
  const visible = PROPERTIES.filter((p) => p.label.toLowerCase().includes(query.toLowerCase()));
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Display properties"
      />
      <MenuContent
        search={<MenuSearch value={query} onValueChange={setQuery} placeholder="Search" />}
      >
        {visible.map((p) => (
          <MenuCheckboxItem
            key={p.key}
            checked={Boolean(checked[p.key])}
            onCheckedChange={(next) => setChecked((c) => ({ ...c, [p.key]: next }))}
            label={p.label}
          />
        ))}
      </MenuContent>
    </Menu>
  );
}

Radio group

Single-select rows wrapped in a MenuRadioGroup sharing one value. The group marks the current row with a trailing tick (marker="check", the default) — the same marker every other single-select row in the library uses. Pass marker="radio" only when this popup also holds a checkbox group, so the two kinds of choice don’t share a marker. That mixed case is on Context Menu.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Menu,
  MenuContent,
  MenuRadioGroup,
  MenuRadioItem,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import * as React from "react";

export default function RadioGroupDemo() {
  const [density, setDensity] = React.useState("comfortable");
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Density"
      />
      <MenuContent>
        <MenuRadioGroup value={density} onValueChange={setDensity}>
          <MenuRadioItem value="comfortable" closeOnClick={false} label="Comfortable" />
          <MenuRadioItem value="compact" closeOnClick={false} label="Compact" />
        </MenuRadioGroup>
      </MenuContent>
    </Menu>
  );
}

Single select

A selected row shows a trailing check at the row’s inline-end; forwarding closeOnClick={false} moves the check without dismissing the menu. Pass selected on every row of a single-select list so unselected rows keep the trailing gutter and the labels hold a common width.

Show code
import { Button } from "@makeplane/propel/components/button";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import * as React from "react";

const SORT_OPTIONS = [
  { key: "manual", label: "Manual" },
  { key: "created", label: "Created date" },
  { key: "due", label: "Due date" },
  { key: "priority", label: "Priority" },
] as const;

export default function SelectedDemo() {
  const [sortBy, setSortBy] = React.useState("manual");
  return (
    <Menu>
      <Button stretch="auto" variant="secondary" size="sm" render={<MenuTrigger />} label="Sort" />
      <MenuContent>
        {SORT_OPTIONS.map((option) => (
          <MenuItem
            key={option.key}
            selected={sortBy === option.key}
            closeOnClick={false}
            onClick={() => setSortBy(option.key)}
            label={option.label}
          />
        ))}
      </MenuContent>
    </Menu>
  );
}

With description

A description gives each row a muted second line; passing it switches the row to a taller, top-aligned layout.

Show code
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import { GlobeOutline, LockOutline } from "@makeplane/propel/icons";
import * as React from "react";

export default function WithDescriptionDemo() {
  const [selected, setSelected] = React.useState("private");
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Visibility"
      />
      <MenuContent>
        <MenuItem
          icon={<Icon icon={LockOutline} tint="secondary" />}
          description="Accessible only by invite"
          selected={selected === "private"}
          closeOnClick={false}
          onClick={() => setSelected("private")}
          label="Private"
        />
        <MenuItem
          icon={<Icon icon={GlobeOutline} tint="secondary" />}
          description="Anyone in the workspace except Guests can join"
          selected={selected === "public"}
          closeOnClick={false}
          onClick={() => setSelected("public")}
          label="Public"
        />
      </MenuContent>
    </Menu>
  );
}

Shortcuts

The trailing slot pins a Shortcut hint at the row’s inline end. The hint is decorative (aria-hidden) — put the canonical binding on the row as aria-keyshortcuts, and wire the actual key handler yourself; neither part listens for keys.

Show code
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import { Shortcut } from "@makeplane/propel/components/shortcut";
import { CopyOutline, NewTabArrowOutline, DeleteOutline } from "@makeplane/propel/icons";

export default function ShortcutsDemo() {
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Actions"
      />
      <MenuContent>
        <MenuItem
          icon={<Icon icon={CopyOutline} tint="secondary" />}
          label="Copy link"
          aria-keyshortcuts="Meta+C"
          trailing={<Shortcut keys="⌘C" />}
        />
        <MenuItem
          icon={<Icon icon={NewTabArrowOutline} tint="secondary" />}
          label="Open in new tab"
        />
        <MenuSeparator />
        <MenuItem
          variant="danger"
          icon={<Icon icon={DeleteOutline} />}
          label="Delete"
          aria-keyshortcuts="Meta+Backspace"
          trailing={<Shortcut keys="⌘⌫" />}
        />
      </MenuContent>
    </Menu>
  );
}

MenuSubmenu nests a MenuSubmenuContent behind a MenuSubmenuTrigger row.

Show code
import { Badge } from "@makeplane/propel/components/badge";
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSubmenu,
  MenuSubmenuContent,
  MenuSubmenuTrigger,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import {
  CircleOutline,
  TickCircleOutline,
  CircleDashedOutline,
  DotOutline,
  CloseCircleOutline,
  PriorityHigh,
  PriorityLow,
  PriorityMedium,
} from "@makeplane/propel/icons";

export default function SubmenuDemo() {
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Filter by"
      />
      <MenuContent>
        <MenuSubmenu>
          <MenuSubmenuTrigger
            trailing={<Badge size="xs" variant="neutral" label="4" />}
            label="Priority"
          />
          <MenuSubmenuContent>
            <MenuItem
              icon={<Icon icon={PriorityHigh} tint="secondary" />}
              closeOnClick={false}
              label="Urgent"
            />
            <MenuItem
              icon={<Icon icon={PriorityHigh} tint="secondary" />}
              closeOnClick={false}
              label="High"
            />
            <MenuItem
              icon={<Icon icon={PriorityMedium} tint="secondary" />}
              closeOnClick={false}
              label="Medium"
            />
            <MenuItem
              icon={<Icon icon={PriorityLow} tint="secondary" />}
              closeOnClick={false}
              label="Low"
            />
          </MenuSubmenuContent>
        </MenuSubmenu>
        <MenuSubmenu>
          <MenuSubmenuTrigger
            trailing={<Badge size="xs" variant="neutral" label="5" />}
            label="State"
          />
          <MenuSubmenuContent>
            <MenuItem
              icon={<Icon icon={CircleDashedOutline} tint="secondary" />}
              closeOnClick={false}
              label="Backlog"
            />
            <MenuItem
              icon={<Icon icon={CircleOutline} tint="secondary" />}
              closeOnClick={false}
              label="Todo"
            />
            <MenuItem
              icon={<Icon icon={DotOutline} tint="secondary" />}
              closeOnClick={false}
              label="In progress"
            />
            <MenuItem
              icon={<Icon icon={TickCircleOutline} tint="secondary" />}
              closeOnClick={false}
              label="Done"
            />
            <MenuItem
              icon={<Icon icon={CloseCircleOutline} tint="secondary" />}
              closeOnClick={false}
              label="Cancelled"
            />
          </MenuSubmenuContent>
        </MenuSubmenu>
      </MenuContent>
    </Menu>
  );
}

MenuLinkItem renders a real anchor, so a row can navigate while keeping menu-item behavior. Marking a row external sets the three things a link out has to carry, together: a trailing arrow, target="_blank", and rel="noreferrer noopener". Rows that stay in the app get none of it, so the arrow means something.

Show code
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import { Menu, MenuContent, MenuLinkItem, MenuTrigger } from "@makeplane/propel/components/menu";
import { BookClosedOutline, BracesOutline, MembersOutline } from "@makeplane/propel/icons";

export default function LinkItemsDemo() {
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Resources"
      />
      <MenuContent>
        <MenuLinkItem
          icon={<Icon icon={BookClosedOutline} tint="secondary" />}
          href="#guides"
          label="Guides"
        />
        <MenuLinkItem
          icon={<Icon icon={BracesOutline} tint="secondary" />}
          href="#api-reference"
          external
          label="API reference"
        />
        <MenuLinkItem
          icon={<Icon icon={MembersOutline} tint="secondary" />}
          href="#community"
          external
          label="Community forum"
        />
      </MenuContent>
    </Menu>
  );
}

Group labels

MenuGroup pairs related rows under a non-interactive MenuLabel heading; a MenuSeparator divides the groups. The heading’s meta slot pins content at its inline end — a count, or a “Clear” action.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Menu,
  MenuCheckboxItem,
  MenuContent,
  MenuGroup,
  MenuItem,
  MenuLabel,
  MenuSeparator,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import * as React from "react";

const SORTS = [
  { key: "date", label: "Date created" },
  { key: "name", label: "Name" },
  { key: "type", label: "Type" },
] as const;

const PANELS = [
  { key: "minimap", label: "Minimap" },
  { key: "search", label: "Search" },
  { key: "sidebar", label: "Sidebar" },
] as const;

export default function GroupLabelsDemo() {
  const [sort, setSort] = React.useState("date");
  const [workspace, setWorkspace] = React.useState<Record<string, boolean>>({ sidebar: true });
  const shown = Object.values(workspace).filter(Boolean).length;
  return (
    <Menu>
      <Button stretch="auto" variant="secondary" size="sm" render={<MenuTrigger />} label="View" />
      <MenuContent>
        <MenuGroup>
          <MenuLabel>Sort</MenuLabel>
          {SORTS.map((s) => (
            <MenuItem
              key={s.key}
              selected={sort === s.key}
              closeOnClick={false}
              onClick={() => setSort(s.key)}
              label={s.label}
            />
          ))}
        </MenuGroup>
        <MenuSeparator />
        <MenuGroup>
          <MenuLabel meta={`${shown} shown`}>Workspace</MenuLabel>
          {PANELS.map((p) => (
            <MenuCheckboxItem
              key={p.key}
              checked={Boolean(workspace[p.key])}
              onCheckedChange={(next) => setWorkspace((w) => ({ ...w, [p.key]: next }))}
              label={p.label}
            />
          ))}
        </MenuGroup>
      </MenuContent>
    </Menu>
  );
}

MenuContent’s footer prop pins sticky chrome below the role="menu" popup — here a MenuFooter with a hint.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Menu,
  MenuContent,
  MenuFooter,
  MenuItem,
  MenuTrigger,
} from "@makeplane/propel/components/menu";

export default function FooterDemo() {
  return (
    <Menu>
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger />}
        label="Assign"
      />
      <MenuContent footer={<MenuFooter>Type to search assignees.</MenuFooter>}>
        <MenuItem label="Amelia Parker" />
        <MenuItem label="David Wilson" />
        <MenuItem label="Sarah Jones" />
      </MenuContent>
    </Menu>
  );
}

Detached triggers

createMenuHandle() links triggers to a Menu defined elsewhere in the tree, so several launch points share one menu without lifting state.

Show code
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import {
  createMenuHandle,
  Menu,
  MenuContent,
  MenuItem,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import { CopyOutline, EditOutline, DeleteOutline } from "@makeplane/propel/icons";

// A handle links triggers that live far from the menu they open, so several
// launch points can share one menu without hoisting controlled state.
const actionsMenu = createMenuHandle();

export default function DetachedTriggersDemo() {
  return (
    <div className="flex items-center gap-2">
      <Button
        stretch="auto"
        variant="secondary"
        size="sm"
        render={<MenuTrigger handle={actionsMenu} />}
        label="Actions"
      />
      <Button
        stretch="auto"
        variant="ghost"
        size="sm"
        render={<MenuTrigger handle={actionsMenu} />}
        label="More"
      />
      <Menu handle={actionsMenu}>
        <MenuContent>
          <MenuItem icon={<Icon icon={EditOutline} tint="secondary" />} label="Edit" />
          <MenuItem icon={<Icon icon={CopyOutline} tint="secondary" />} label="Make a copy" />
          <MenuItem variant="danger" icon={<Icon icon={DeleteOutline} />} label="Delete" />
        </MenuContent>
      </Menu>
    </div>
  );
}

Fit height

By default (heightBehavior="fit") a menu that cannot fit its full height shrinks before flipping: it keeps its side while the space there clears a 178px floor (six rows plus the panel’s chrome), scrolls, and flips only when it cannot and the other side can. heightBehavior="flip" restores Base UI’s behavior — jump to the other side of the trigger whenever that side has more room, even when this side held a usable menu. Both menus below open from the same spot in a bounded box; flip jumps above the trigger, the default stays below.

Show code
import { Button } from "@makeplane/propel/components/button";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import * as React from "react";

const OPTIONS = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`);

// Both menus open from the same spot, just past the middle of a bounded box:
// enough room below for a six-row menu, slightly more above. The flip opt-out
// jumps the trigger for that slightly-more; the default stays below and scrolls.
export default function FitHeightDemo() {
  const [boundary, setBoundary] = React.useState<HTMLElement | null>(null);
  return (
    <div
      ref={setBoundary}
      className="relative w-full max-w-lg rounded-lg border border-subtle"
      style={{ height: 480 }}
    >
      <div className="absolute flex gap-2" style={{ top: 240, left: 16 }}>
        <Menu>
          <Button
            stretch="auto"
            variant="secondary"
            size="sm"
            render={<MenuTrigger />}
            label="Flip"
          />
          <MenuContent heightBehavior="flip" collisionBoundary={boundary ?? undefined}>
            {OPTIONS.map((label) => (
              <MenuItem key={label} closeOnClick={false} label={label} />
            ))}
          </MenuContent>
        </Menu>
        <Menu>
          <Button
            stretch="auto"
            variant="secondary"
            size="sm"
            render={<MenuTrigger />}
            label="Fit (default)"
          />
          <MenuContent collisionBoundary={boundary ?? undefined}>
            {OPTIONS.map((label) => (
              <MenuItem key={label} closeOnClick={false} label={label} />
            ))}
          </MenuContent>
        </Menu>
      </div>
    </div>
  );
}

Sizing

A menu is as wide as its rows. sizing="auto" (the default) floors that at 204px, so a one-word menu still reads as part of the family and a search field has room, and caps it at 400px or the room beside the trigger, past which a long row truncates. The panel holds its width while it is open: it may grow as rows arrive and never shrinks until it closes, so a searchable list keeps the width it opened at while typing filters it down. sizing="anchor" matches the trigger’s width instead; submenus share the same rule. Flip the first switch to give the rows a long label, and type in the second menu to watch the panel hold. The bounds and their tokens are on the Sizing page.

Rows outgrow the floor
Show code
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSearch,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import { Switch } from "@makeplane/propel/components/switch";
import { TickCircleOutline, CircleDashedOutline, DotOutline } from "@makeplane/propel/icons";
import * as React from "react";

const SHORT_ROWS = [
  { label: "Draft", icon: CircleDashedOutline },
  { label: "In review", icon: DotOutline },
  { label: "Shipped", icon: TickCircleOutline },
];

const LONG_ROWS = [
  { label: "Draft", icon: CircleDashedOutline },
  { label: "Waiting on the platform team's sign-off", icon: DotOutline },
  { label: "Approved — ships with the next release rollout", icon: TickCircleOutline },
];

const PEOPLE = [
  "Ada",
  "Grace Hopper",
  "Katherine Johnson",
  "Margaret Hamilton — mission software lead",
  "Mary Allen Wilkes",
];

// Two menus, no rung chosen on either. The first shows the bounds: short rows sit on the 204px
// floor, long rows widen the panel to the widest of them, up to the 400px ceiling. The second
// shows the hold: the list opens at the width of its longest row and keeps it while typing
// filters the rows down.
export default function SizingDemo() {
  const [long, setLong] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const rows = long ? LONG_ROWS : SHORT_ROWS;
  const people = PEOPLE.filter((name) => name.toLowerCase().includes(query.toLowerCase()));
  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center gap-2 text-body-sm-regular text-secondary">
        <Switch
          size="md"
          aria-label="Rows outgrow the floor"
          checked={long}
          onCheckedChange={setLong}
        />
        Rows outgrow the floor
      </div>
      <div className="flex flex-wrap gap-2">
        <Menu>
          <Button
            stretch="auto"
            variant="secondary"
            size="sm"
            render={<MenuTrigger />}
            label="Status"
          />
          <MenuContent>
            {rows.map((row) => (
              <MenuItem
                key={row.label}
                icon={<Icon icon={row.icon} tint="secondary" />}
                closeOnClick={false}
                label={row.label}
              />
            ))}
          </MenuContent>
        </Menu>
        <Menu onOpenChange={(open) => (open ? undefined : setQuery(""))}>
          <Button
            stretch="auto"
            variant="secondary"
            size="sm"
            render={<MenuTrigger />}
            label="Assignee"
          />
          <MenuContent
            search={
              <MenuSearch value={query} onValueChange={setQuery} placeholder="Search people" />
            }
          >
            {people.map((name) => (
              <MenuItem key={name} closeOnClick={false} label={name} />
            ))}
            {people.length === 0 ? <MenuItem disabled label="No one matches" /> : null}
          </MenuContent>
        </Menu>
      </div>
    </div>
  );
}

API Reference

Menu

The menu Root — Base UI's context/state provider (renders no element of its own). A behavior-only role, so it lives in components (rules 1a, 2); the styled parts live in elements/menu and are grafted onto Base UI behavior here.

PropTypeDefaultDescription
openboolean—Whether the menu is open. Controlled; pair with onOpenChange.
defaultOpenbooleanfalseWhether the menu is open on mount. Uncontrolled.
onOpenChange((open: boolean, eventDetails: MenuRootChangeEventDetails) => void)—Called with the next open state when the menu opens or closes.
modalbooleantrueWhether the menu enters a modal state when open, like Select: page scroll is locked and a press outside the menu only dismisses it. The trigger is cut out of the backdrop. Set false to leave the rest of the page interactive while the menu is open. Submenus ignore this prop, and a menu opened by hover is never modal.
childrenReactNode | PayloadChildRenderFunction<Payload>—The trigger and menu surface (MenuTrigger, MenuContent).

MenuTrigger

The button that opens the menu — Base UI's Menu.Trigger passthrough. Propel ships no menu trigger chrome (any control can open a menu), so graft the behavior onto the control you already have via render (<MenuTrigger render={<Button …/>}>); Base UI contributes the open/close behavior, ARIA wiring, and data-* state. The trigger also records its element for MenuContent's default heightBehavior="fit" to measure against. A consumer ref is chained after that recording rather than spread over it: in the forward graft (<Button render={<MenuTrigger />} />, <Pill render={<MenuTrigger />} />) Base UI clones this element with the button's own ref in its props, and a {...props} after ref={record} would replace the recorder — nothing recorded, fit silently falling back to flip. Same shape as PopoverTrigger.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, MenuTriggerState>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

MenuContent

The menu surface: Base UI's portal and positioner wrapped around an elevated, scrollable panel holding the role="menu" popup. Optional search and footer chrome stays pinned outside the scroll area. Rows (MenuItem, MenuGroup, MenuSeparator, …) are its children.

PropTypeDefaultDescription
footerReactNode—Sticky chrome pinned below the role="menu" popup, e.g. a MenuFooter.
searchReactNode—A search field pinned above the role="menu" popup, e.g. a MenuSearch. The menu focuses it when it opens, Escape closes from inside it, and ArrowDown/ArrowUp move focus into the list.
align"center" | "start" | "end"startAlignment of the menu relative to the trigger along side.
alignOffsetnumber | OffsetFunction0Additional offset in px along the align axis — slides the menu along the trigger's edge without changing align.
collisionBoundaryBoundary—The element or rect the menu is confined to when avoiding collisions. Base UI's clipping ancestors when unset.
collisionAvoidanceCollisionAvoidance—What to do when the menu would overflow the boundary, set per axis: flip to the other side, shift along it, or stay put. Base UI flips the side and shifts the alignment when unset.
stickybooleanfalseKeep the menu on screen after the trigger scrolls out of view, instead of following it out.
positionMethod"absolute" | "fixed""absolute"Which CSS position the positioner uses. fixed escapes overflow-clipping ancestors, e.g. a trigger inside a sticky header.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null—Position against this element instead of the trigger — an element, a ref, a virtual element, or a function returning one. Geometry only; the trigger keeps its role and interactions.
disableAnchorTrackingbooleanfalseStop tracking layout shifts of the anchor — position once, on open.
side"top" | "bottom" | "left" | "right" | "inline-end" | "inline-start"bottomWhich side of the trigger the menu opens toward.
sideOffsetnumber | OffsetFunction6Distance in px between the trigger and the menu.
collisionPaddingPadding16Minimum gap in px between the menu and the viewport edge it would otherwise touch.
sizing"menu" | "anchor" | "auto" | "sm" | "md" | "lg"autoHow the menu popup takes its width. auto is the content's width — never narrower than 204px, never wider than 400px or the room beside the trigger, and held while open so a list that filters down does not reflow. anchor matches the trigger's width instead, growing only with its content. The default applies to an omitted _and_ an explicitly undefined value.
heightBehavior"flip" | "fit"fitWhat decides which side of the trigger the menu opens on when space is short. fit, the default, shrinks before flipping: the menu keeps side while the usable space there clears a 178px floor (six rows plus the panel's chrome), and flips only when it does not and the other side does; when neither clears the floor, the bigger side wins. The side is resolved each time the menu opens and holds while it stays open. flip is Base UI's behavior: jump to the other side whenever the menu's full height does not fit and the other side has more room — even when this side held a usable menu. fit applies to a vertical side, needs the menu's own MenuTrigger to measure against (a menu opened without one falls back to flip), and defers to an explicit collisionAvoidance.

MenuItem

The ready-made selectable menu row: grafts Base UI's Menu.Item behavior onto the styled MenuItem and lays out an optional leading icon, the label, a secondary line, the single-select check, and trailing content.

PropTypeDefaultDescription
label(required)string—Primary row label.
renderReactElement<unknown, string | JSXElementConstructor<any>>—Element the row renders as, when a <div> is not the right one — e.g. render={<a href=… />} for a row that navigates. The row's own styling and menu behavior still apply. Narrower than Base UI's render: the styled row is the graft target, so a state-aware render function has no row state to receive.
variant"neutral" | "accent" | "danger""neutral"Row look. neutral is the standard text hierarchy, accent marks the primary action, and danger marks a destructive one.
iconReactNode—Leading element before the label, e.g. <Icon icon={Settings} tint="secondary" />.
descriptionstring—Muted secondary line under the label.
secondaryTextstring—Muted text shown inline after the label.
trailingReactNode—Content pinned at the row's inline end, e.g. <Shortcut keys="⌘ K" /> or a count <Badge />.
selectedboolean—Single-select selected state. Pass it on **every** row of a single-select list (true or false) so each row reserves the trailing check gutter and its labels stay a common width; omit it entirely on plain action rows, which then render no gutter. Passing it also makes the row a menuitemradio with aria-checked, so the current value is announced rather than left to the check glyph. That is why the boolean must be real on every row of the set: a mix of selected and omitted rows produces a mix of menuitemradio and menuitem, which reads as one radio in a list of commands.

MenuCheckboxItem

The ready-made toggleable multi-select menu row: grafts Base UI's Menu.CheckboxItem behavior onto the styled MenuCheckboxItem and lays out the checkbox box (a kept-mounted Menu.CheckboxItemIndicator grafted onto MenuCheckboxItemIndicator + TickOutline), optional leading icon, label, and end content. Base UI's Menu.CheckboxItem tracks the checked state, so checked / defaultChecked / onCheckedChange forward straight to it and the indicator reads it from context.

PropTypeDefaultDescription
label(required)string—Primary row label.
variant"neutral" | "accent" | "danger""neutral"Row look. neutral is the standard text hierarchy, accent marks the primary action, and danger marks a destructive one.
iconReactNode—Leading element shown after the checkbox, e.g. <Icon icon={Settings} tint="secondary" />.
trailingReactNode—Content pinned at the row's inline end, e.g. <Shortcut keys="⌘ K" /> or a count <Badge />.

MenuRadioGroup

Wraps MenuRadioItems sharing one value — Base UI's radio-group state, no element chrome.

PropTypeDefaultDescription
childrenReactNode—The content of the component.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, MenuRadioGroupState>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
marker"radio" | "check"checkHow the group marks its current row. Defaults to check — the trailing tick every other single-select row uses. Pass radio only when this group shares a popup with a multi-select (checkbox) group and the two need telling apart.

MenuRadioItem

The ready-made single-select menu row: grafts Base UI's Menu.RadioItem behavior onto the styled MenuRadioItem and marks the current row the way its MenuRadioGroup's marker says. check (the default): a kept-mounted trailing tick (Menu.RadioItemIndicator grafted onto MenuRadioItemCheck) that shows on the selected row, the same mark every other single-select row in the library uses. radio: a leading ring (MenuRadioItemIndicator in a MenuItemControl gutter) drawn on every row with only its inner dot hiding, for a popup that also holds a checkbox group and needs the two kinds of choice told apart. keepMounted is load-bearing either way: the indicator holds its slot and carries data-checked / data-unchecked for the styling to key off. Wrap rows in a MenuRadioGroup carrying value/onValueChange; both the selected state and the marker come from context.

PropTypeDefaultDescription
label(required)string—Primary row label.
variant"neutral" | "accent" | "danger""neutral"Row look. neutral is the standard text hierarchy, accent marks the primary action, and danger marks a destructive one.
iconReactNode—Leading element, e.g. <Icon icon={Settings} tint="secondary" />. With the group's marker="radio" it sits after the ring; with the default tick it leads the row.
trailingReactNode—Content pinned at the row's inline end, e.g. <Shortcut keys="⌘ K" /> or a count <Badge />.

MenuLinkItem

The ready-made navigational <a> menu row: grafts Base UI's Menu.LinkItem behavior onto the styled MenuLinkItem and lays out an optional leading icon, label, and end content.

PropTypeDefaultDescription
label(required)string—Primary row label.
variant"neutral" | "accent" | "danger""neutral"Row look. neutral is the standard text hierarchy, accent marks the primary action, and danger marks a destructive one.
iconReactNode—Leading element before the label, e.g. <Icon icon={ExternalLink} tint="secondary" />.
trailingReactNode—Content pinned at the row's inline end, e.g. <Shortcut keys="⌘ K" /> or a count <Badge />.
externalbooleanfalseThe row leaves the app. Sets the three things a link out has to carry, together: a trailing arrow so the row says where it goes, target="_blank", and rel="noreferrer noopener". They are one prop because they are one decision, and because two of them are easy to forget — a missing rel is a security default, not a style nit. Both are overridable: pass your own target or rel and it wins. Passing trailing also wins, in which case the row is yours to annotate.

MenuGroup

Groups related menu items with their MenuLabel heading — Base UI's Menu.Group passthrough. A structural role with no propel styling of its own, so it lives in components (rules 1a, 2).

PropTypeDefaultDescription
childrenReactNode—The content of the component.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, MenuGroupState>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

MenuLabel

A non-interactive section heading for a group of menu items: grafts Base UI's Menu.GroupLabel behavior onto the styled MenuLabel. Place it inside the MenuGroup it names, so Base UI wires it as that group's accessible label.

PropTypeDefaultDescription
childrenReactNode—The heading text.
metaReactNode—Content pinned at the heading row's inline end, e.g. a count or a "Clear" action.

MenuSeparator

Ready-made divider between groups of menu items: grafts Base UI's Menu.Separator behavior (role="separator") onto the styled MenuSeparator element.

No component-specific props — accepts the standard HTML attributes for the element it renders.

MenuSubmenu

The submenu Root — Base UI's SubmenuRoot context/state provider (renders no element of its own). A behavior-only role, so it lives in components (rules 1a, 2).

PropTypeDefaultDescription
openboolean—Whether the submenu is open. Controlled; pair with onOpenChange.
defaultOpenbooleanfalseWhether the submenu is open on mount. Uncontrolled.
onOpenChange((open: boolean, eventDetails: MenuRootChangeEventDetails) => void)—Called with the next open state when the submenu opens or closes.
childrenReactNode—The submenu's trigger and nested content.

MenuSubmenuTrigger

The ready-made submenu trigger row: grafts Base UI's Menu.SubmenuTrigger behavior onto the styled MenuSubmenuTrigger and lays out an optional leading icon, label, end content, and the submenu chevron indicator.

PropTypeDefaultDescription
label(required)string—Primary row label.
variant"neutral" | "accent" | "danger""neutral"Row look. neutral is the standard text hierarchy, accent marks the primary action, and danger marks a destructive one.
iconReactNode—Leading element before the label, e.g. <Icon icon={Folder} tint="secondary" />.
trailingReactNode—Content pinned before the submenu chevron, e.g. <Shortcut keys="⌘ K" /> or a count <Badge />.

MenuSubmenuContent

The floating surface for a submenu — the same elevated, scrollable panel as MenuContent, but anchored beside its parent row instead of below a trigger. Place it inside a MenuSubmenu, alongside the MenuSubmenuTrigger that opens it.

PropTypeDefaultDescription
footerReactNode—Sticky chrome pinned below the role="menu" popup, e.g. a MenuFooter.
searchReactNode—A search field pinned above the role="menu" popup, e.g. a MenuSearch. The menu focuses it when it opens, Escape closes from inside it, and ArrowDown/ArrowUp move focus into the list.
sideOffsetnumber | OffsetFunction4Distance in px between the trigger and the menu.
align"center" | "start" | "end"startAlignment of the menu relative to the trigger along side.
alignOffsetnumber | OffsetFunction0Additional offset in px along the align axis — slides the menu along the trigger's edge without changing align.
collisionPaddingPadding—Minimum gap in px between the menu and the viewport edge it would otherwise touch.
collisionBoundaryBoundary—The element or rect the menu is confined to when avoiding collisions. Base UI's clipping ancestors when unset.
collisionAvoidanceCollisionAvoidance—What to do when the menu would overflow the boundary, set per axis: flip to the other side, shift along it, or stay put. Base UI flips the side and shifts the alignment when unset.
stickybooleanfalseKeep the menu on screen after the trigger scrolls out of view, instead of following it out.
positionMethod"absolute" | "fixed""absolute"Which CSS position the positioner uses. fixed escapes overflow-clipping ancestors, e.g. a trigger inside a sticky header.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null—Position against this element instead of the trigger — an element, a ref, a virtual element, or a function returning one. Geometry only; the trigger keeps its role and interactions.
disableAnchorTrackingbooleanfalseStop tracking layout shifts of the anchor — position once, on open.
side"top" | "right" | "bottom" | "left" | "inline-end" | "inline-start"rightWhich side of the parent row the submenu opens toward.
sizing"menu" | "anchor" | "auto" | "sm" | "md" | "lg"autoHow the submenu popup takes its width — the same rule as MenuContent: auto is the content's width between the 204px floor and the 400px ceiling, held while open; anchor matches the parent row's width.

MenuSearch

A sticky search field pinned above a MenuContent popup, via its search prop. The menu focuses it on open (it sits outside the role="menu" popup — a menu may not own a textbox — so the hand-off is programmatic, not Tab). Text-editing keys stay in the field, which keeps the menu's type-ahead from stealing them; Escape closes the menu, and ArrowDown/ArrowUp move focus into the list — first row down, last row up.

PropTypeDefaultDescription
placeholderstring—Placeholder text.
valuestring—Current search text. Omit it to let the field manage its own state (see defaultValue).
defaultValuestring—Initial search text when the field manages its own state.
onValueChange((value: string) => void)—Called with the new text on each keystroke — and with "" when the menu closes (see resetOnClose).
resetOnClosebooleantrueClear the query when the menu closes. The field's own state resets with the popup unmount; this also calls onValueChange("") on close so a consumer-held query — which outlives the popup — is cleared too and the list is unfiltered on reopen. Turn it off to keep a _controlled_ query across opens on purpose; an uncontrolled field (defaultValue) resets with the popup either way, so all the flag changes there is whether onValueChange("") fires.

MenuFooter

A non-interactive footer pinned below a menu popup list (sticky chrome outside role="menu").

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

MenuViewport

The content-morph container for animated menus — Base UI's Menu.Viewport grafted onto the shared relative viewport, so swapping popup content (e.g. drill-in panels) can transition sizes.

PropTypeDefaultDescription
childrenReactNode—The content to render inside the transition container.