Propel

Context Menu

A menu of actions that opens at the pointer on right click or long press.

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

Right-click here
Show code
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@makeplane/propel/components/context-menu";
import { Icon } from "@makeplane/propel/components/icon";
import { Shortcut } from "@makeplane/propel/components/shortcut";
import { ClipboardOutline, CopyOutline, UndoOutline, DeleteOutline } from "@makeplane/propel/icons";

const triggerClass =
  "flex h-32 w-72 items-center justify-center rounded-lg border-sm border-dashed border-subtle text-body-xs-regular text-tertiary select-none";

export default function BasicDemo() {
  return (
    <ContextMenu>
      <ContextMenuTrigger render={<div className={triggerClass} />}>
        Right-click here
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem
          icon={<Icon icon={UndoOutline} />}
          aria-keyshortcuts="Meta+Z"
          trailing={<Shortcut keys="⌘Z" />}
          label="Undo"
        />
        <ContextMenuItem
          icon={<Icon icon={CopyOutline} />}
          aria-keyshortcuts="Meta+C"
          trailing={<Shortcut keys="⌘C" />}
          label="Copy"
        />
        <ContextMenuItem
          icon={<Icon icon={ClipboardOutline} />}
          aria-keyshortcuts="Meta+V"
          trailing={<Shortcut keys="⌘V" />}
          label="Paste"
        />
        <ContextMenuSeparator />
        <ContextMenuItem variant="danger" icon={<Icon icon={DeleteOutline} />} label="Delete" />
      </ContextMenuContent>
    </ContextMenu>
  );
}

Installation

import {
  ContextMenu,
  ContextMenuTrigger,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
} from "@makeplane/propel/components/context-menu";

Usage

import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@makeplane/propel/components/context-menu";
import { Icon } from "@makeplane/propel/components/icon";
import { Shortcut } from "@makeplane/propel/components/shortcut";
import { ClipboardOutline, CopyOutline, UndoOutline, DeleteOutline } from "@makeplane/propel/icons";

const triggerClass =
  "flex h-32 w-72 items-center justify-center rounded-lg border-sm border-dashed border-subtle text-body-xs-regular text-tertiary select-none";

export default function BasicDemo() {
  return (
    <ContextMenu>
      <ContextMenuTrigger render={<div className={triggerClass} />}>
        Right-click here
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem
          icon={<Icon icon={UndoOutline} />}
          aria-keyshortcuts="Meta+Z"
          trailing={<Shortcut keys="⌘Z" />}
          label="Undo"
        />
        <ContextMenuItem
          icon={<Icon icon={CopyOutline} />}
          aria-keyshortcuts="Meta+C"
          trailing={<Shortcut keys="⌘C" />}
          label="Copy"
        />
        <ContextMenuItem
          icon={<Icon icon={ClipboardOutline} />}
          aria-keyshortcuts="Meta+V"
          trailing={<Shortcut keys="⌘V" />}
          label="Paste"
        />
        <ContextMenuSeparator />
        <ContextMenuItem variant="danger" icon={<Icon icon={DeleteOutline} />} label="Delete" />
      </ContextMenuContent>
    </ContextMenu>
  );
}

Examples

Nest a ContextMenuSubmenu to reveal more destinations beside the parent menu. Root and submenu both size themselves auto: as wide as their rows, between the 204px floor and the 400px ceiling. The rule is on the Sizing page.

Right-click here
Show code
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuSubmenu,
  ContextMenuSubmenuContent,
  ContextMenuSubmenuTrigger,
  ContextMenuTrigger,
} from "@makeplane/propel/components/context-menu";
import { Icon } from "@makeplane/propel/components/icon";
import {
  CopyOutline,
  FolderOpenOutline,
  EditOutline,
  DeleteOutline,
} from "@makeplane/propel/icons";

const triggerClass =
  "flex h-32 w-72 items-center justify-center rounded-lg border-sm border-dashed border-subtle text-body-xs-regular text-tertiary select-none";

export default function SubmenuDemo() {
  return (
    <ContextMenu>
      <ContextMenuTrigger render={<div className={triggerClass} />}>
        Right-click here
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem icon={<Icon icon={EditOutline} />} label="Rename" />
        <ContextMenuItem icon={<Icon icon={CopyOutline} />} label="Duplicate" />
        <ContextMenuSubmenu>
          <ContextMenuSubmenuTrigger icon={<Icon icon={FolderOpenOutline} />} label="Move to" />
          <ContextMenuSubmenuContent>
            <ContextMenuItem label="Mobile app" />
            <ContextMenuItem label="Web app" />
            <ContextMenuItem label="Design system" />
          </ContextMenuSubmenuContent>
        </ContextMenuSubmenu>
        <ContextMenuSeparator />
        <ContextMenuItem variant="danger" icon={<Icon icon={DeleteOutline} />} label="Delete" />
      </ContextMenuContent>
    </ContextMenu>
  );
}

ContextMenuLinkItem renders a real anchor, so a row can navigate while keeping menu-item behavior. Pass external for outbound links (target="_blank", rel="noreferrer noopener", trailing arrow).

Right-click here
Show code
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuLinkItem,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@makeplane/propel/components/context-menu";
import { Icon } from "@makeplane/propel/components/icon";
import { Shortcut } from "@makeplane/propel/components/shortcut";
import { CopyOutline, NewTabArrowOutline, LinkOutline } from "@makeplane/propel/icons";

const triggerClass =
  "flex h-32 w-72 items-center justify-center rounded-lg border-sm border-dashed border-subtle text-body-xs-regular text-tertiary select-none";

export default function LinkItemsDemo() {
  return (
    <ContextMenu>
      <ContextMenuTrigger render={<div className={triggerClass} />}>
        Right-click here
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuLinkItem
          icon={<Icon icon={NewTabArrowOutline} />}
          href="#work-item"
          external
          label="Open in new tab"
        />
        <ContextMenuLinkItem
          icon={<Icon icon={LinkOutline} />}
          href="#activity"
          label="Go to activity"
        />
        <ContextMenuSeparator />
        <ContextMenuItem
          icon={<Icon icon={CopyOutline} />}
          aria-keyshortcuts="Meta+C"
          trailing={<Shortcut keys="⌘C" />}
          label="Copy link"
        />
      </ContextMenuContent>
    </ContextMenu>
  );
}

Selection rows

ContextMenuCheckboxItem toggles independently; ContextMenuRadioItem rows wrapped in a ContextMenuRadioGroup share one value. Both pass closeOnClick={false} so the menu stays open while you adjust the options. The radio group passes marker="radio" because this popup also holds a checkbox row: next to a box, the default trailing tick would make the two kinds of choice read the same. A single-select group on its own keeps the default tick and no leading gutter.

Right-click for view options
Show code
import {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuRadioGroup,
  ContextMenuRadioItem,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@makeplane/propel/components/context-menu";
import * as React from "react";

const triggerClass =
  "flex h-32 w-72 items-center justify-center rounded-lg border-sm border-dashed border-subtle text-body-xs-regular text-tertiary select-none";

export default function SelectionRowsDemo() {
  const [wrap, setWrap] = React.useState(true);
  const [sort, setSort] = React.useState("manual");

  return (
    <ContextMenu>
      <ContextMenuTrigger render={<div className={triggerClass} />}>
        Right-click for view options
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuCheckboxItem
          checked={wrap}
          onCheckedChange={setWrap}
          closeOnClick={false}
          label="Wrap titles"
        />
        <ContextMenuSeparator />
        {/* This popup also holds a checkbox row, so the group opts into the ring: a box on one line
            and a trailing tick on the others would read as two different kinds of choice. */}
        <ContextMenuRadioGroup value={sort} onValueChange={setSort} marker="radio">
          <ContextMenuRadioItem value="manual" closeOnClick={false} label="Manual order" />
          <ContextMenuRadioItem value="updated" closeOnClick={false} label="Last updated" />
        </ContextMenuRadioGroup>
      </ContextMenuContent>
    </ContextMenu>
  );
}

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.

Right-click for sort options
Show code
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuTrigger,
} from "@makeplane/propel/components/context-menu";
import * as React from "react";

const triggerClass =
  "flex h-32 w-72 items-center justify-center rounded-lg border-sm border-dashed border-subtle text-body-xs-regular text-tertiary select-none";

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 (
    <ContextMenu>
      <ContextMenuTrigger render={<div className={triggerClass} />}>
        Right-click for sort options
      </ContextMenuTrigger>
      <ContextMenuContent>
        {SORT_OPTIONS.map((option) => (
          <ContextMenuItem
            key={option.key}
            selected={sortBy === option.key}
            closeOnClick={false}
            onClick={() => setSortBy(option.key)}
            label={option.label}
          />
        ))}
      </ContextMenuContent>
    </ContextMenu>
  );
}

With description

A description gives each row a muted second line; passing it switches the row to a taller, top-aligned layout, so the icon and check line up with the label.

Right-click to change visibility
Show code
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuTrigger,
} from "@makeplane/propel/components/context-menu";
import { Icon } from "@makeplane/propel/components/icon";
import { GlobeOutline, LockOutline } from "@makeplane/propel/icons";
import * as React from "react";

const triggerClass =
  "flex h-32 w-72 items-center justify-center rounded-lg border-sm border-dashed border-subtle text-body-xs-regular text-tertiary select-none";

export default function WithDescriptionDemo() {
  const [visibility, setVisibility] = React.useState("private");
  return (
    <ContextMenu>
      <ContextMenuTrigger render={<div className={triggerClass} />}>
        Right-click to change visibility
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem
          icon={<Icon icon={LockOutline} tint="secondary" />}
          description="Accessible only by invite"
          selected={visibility === "private"}
          closeOnClick={false}
          onClick={() => setVisibility("private")}
          label="Private"
        />
        <ContextMenuItem
          icon={<Icon icon={GlobeOutline} tint="secondary" />}
          description="Anyone in the workspace except Guests can join"
          selected={visibility === "public"}
          closeOnClick={false}
          onClick={() => setVisibility("public")}
          label="Public"
        />
      </ContextMenuContent>
    </ContextMenu>
  );
}

API Reference

ContextMenu

Root of the context menu: holds the open state and the pointer position the menu anchors to, and renders no element of its own. Wrap a ContextMenuTrigger and a ContextMenuContent in it; the menu opens on right click or long press anywhere inside the trigger.

PropTypeDefaultDescription
openboolean—Whether the menu is open. Controlled; pair with onOpenChange.
defaultOpenbooleanfalseWhether the menu is open on mount. Uncontrolled.
onOpenChange((open: boolean, eventDetails: ContextMenuRootChangeEventDetails) => void)—Called with the next open state when the menu opens or closes.
childrenReactNode—The trigger and menu surface (ContextMenuTrigger, ContextMenuContent).

ContextMenuTrigger

The behavior surface that opens the menu on right click or long press — Base UI applies no styling of its own, so give it a look by grafting it onto your own element via render: ``tsx <ContextMenuTrigger render={<MyCanvasArea />}>Right-click here</ContextMenuTrigger>; Maps 1:1 to ContextMenu.Trigger`.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ContextMenuTriggerState>—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.

ContextMenuContent

The root context-menu surface: Base UI's portal and positioner wrapped around an elevated, scrollable OverlayPanel holding the flat role="menu" popup. Anchors to the pointer and sizes itself like a dropdown. Rows (ContextMenuItem, ContextMenuSeparator, …) are its children. A submenu's own surface is ContextMenuSubmenuContent, not this part.

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right" | "inline-end" | "inline-start"—Which side of the anchor the popup opens toward. Leave it unset at the root: an explicit side opts out of Base UI's pointer-hugging offsets, so the menu detaches from the cursor. A submenu opens toward inline-end unless told otherwise.
sideOffsetnumber | OffsetFunction—Distance in px between the anchor and the popup.
align"center" | "start" | "end"—Alignment of the popup relative to the anchor along side.
alignOffsetnumber | OffsetFunction0Additional offset in px along the align axis — slides the popup along the anchor's edge without changing align.
collisionPaddingPadding16Minimum gap in px between the popup and the viewport edge it would otherwise touch.
collisionBoundaryBoundary—The element or rect the popup is confined to when avoiding collisions. Base UI's clipping ancestors when unset.
collisionAvoidanceCollisionAvoidance—What to do when the popup 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 popup on screen after its anchor scrolls out of view, instead of following it out.
positionMethod"absolute" | "fixed""absolute"Which CSS position the positioner uses. fixed escapes overflow-clipping ancestors.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null—Position against this element instead of the pointer — an element, a ref, a virtual element, or a function returning one. Geometry only; right-click targeting is unchanged.
disableAnchorTrackingbooleanfalseStop tracking layout shifts of the anchor — position once, on open.
sizing"menu" | "anchor" | "auto" | "sm" | "md" | "lg"autoHow the menu popup is sized — the same rule as Menu. auto is the content's width between the 204px floor and 400px ceiling, held while open; anchor matches the anchor's width instead. The deprecated menu/sm/md/lg aliases render as auto. The default applies to an omitted _and_ an explicitly undefined value.

ContextMenuItem

The ready-made menu row: grafts Base UI's Item behavior onto the styled ContextMenuItem and composes its region parts — an optional leading icon, the label and an optional description, the single-select check, and optional trailing content.

PropTypeDefaultDescription
label(required)string—Primary row label.
variant"neutral" | "danger""neutral"Row look. neutral is the standard text hierarchy and danger marks a destructive action.
iconReactNode—Leading element before the label, e.g. <Icon icon={Copy} />.
descriptionstring—Muted secondary line under 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** ContextMenuItem 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. ContextMenuLinkItem and ContextMenuSubmenuTrigger have no selected prop or gutter of their own — mixing either into the same list as selected rows gives their labels more room than the checked ones.

ContextMenuCheckboxItem

The ready-made toggleable context-menu row: grafts Base UI's CheckboxItem behavior onto the styled row and bakes a kept-mounted tick indicator, so checked / defaultChecked / onCheckedChange forward straight to Base UI and the tick reads state from context.

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

ContextMenuRadioGroup

Wraps ContextMenuRadioItems sharing one value — Base UI's radio-group state, no 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.

ContextMenuRadioItem

The ready-made single-select context-menu row: grafts Base UI's RadioItem behavior onto the styled row and marks the current row the way its ContextMenuRadioGroup's marker says. check (the default): a kept-mounted trailing tick on the selected row. radio: a leading ring drawn on every row with only its inner dot hiding, for a popup that also holds a checkbox group. Wrap rows in a ContextMenuRadioGroup carrying value/onValueChange; both the selected state and the marker come from context.

PropTypeDefaultDescription
label(required)string—Primary row label.
variant"neutral" | "danger""neutral"Row look. neutral is the standard text hierarchy and danger marks a destructive action.
iconReactNode—Leading element, e.g. <Icon icon={AlignLeft} />. 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 />.

ContextMenuLinkItem

The ready-made navigational menu row: grafts Base UI's LinkItem behavior onto the styled ContextMenuLinkItem and composes its region parts — a leading icon, the label, and optional trailing content.

PropTypeDefaultDescription
label(required)string—Primary row label.
variant"neutral" | "danger""neutral"Row look. neutral is the standard text hierarchy and danger marks a destructive action.
iconReactNode—Leading element before the label, e.g. <Icon icon={ExternalLink} />.
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.

ContextMenuGroup

Groups related rows; provide aria-label/aria-labelledby when the group needs a name.

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.

ContextMenuSeparator

Ready-made divider between groups of items: Base UI's ContextMenu.Separator behavior (the separator role) grafted onto the styled thin rule.

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

ContextMenuSubmenu

Groups all parts of a nested submenu — its ContextMenuSubmenuTrigger row and the ContextMenuSubmenuContent that opens beside it. Holds the submenu's own open state and renders no element of its own.

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 row and the ContextMenuContent it opens.

ContextMenuSubmenuTrigger

The ready-made submenu trigger: grafts Base UI's SubmenuTrigger behavior onto the styled ContextMenuSubmenuTrigger and composes its region parts — a leading icon, the label, optional trailing content, and the caret that points toward the submenu.

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