Skip to content

Context menu

The right-click menu for rows, cards and canvas nodes, mirroring a visible menu.

Category
Overlays
Adoption
Not used yet
import { ContextMenu } from "@oration/canon/components/context-menu";
packages/canon/src/components/context-menu.tsx
  • INV-20931Northwind Freight$18,240.00
  • INV-20874Halcyon$9,612.50
  • INV-20802Orchard Street$22,005.10

Right-click a row, or use its More actions button.

import { Button } from "@oration/canon/components/button";import {  ContextMenu,  ContextMenuContent,  ContextMenuItem,  ContextMenuSeparator,  ContextMenuShortcut,  ContextMenuTrigger,} from "@oration/canon/components/context-menu";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuSeparator,  DropdownMenuShortcut,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import {  CopyIcon,  ExternalLinkIcon,  LinkIcon,  MoreHorizontalIcon,  Trash2Icon,} from "lucide-react";export function Hero() {    const invoices = [        {            id: "INV-20931",            supplier: "Northwind Freight",            amount: "$18,240.00",        },        { id: "INV-20874", supplier: "Halcyon", amount: "$9,612.50" },        { id: "INV-20802", supplier: "Orchard Street", amount: "$22,005.10" },    ];    const actions = (id: string) => [        {            label: "Open invoice",            icon: ExternalLinkIcon,            shortcut: "↵",            run: () => toast.add({ title: `Opened ${id}` }),        },        {            label: "Duplicate",            icon: CopyIcon,            shortcut: "⌘D",            run: () => toast.add({ title: `${id} duplicated` }),        },        {            label: "Copy link",            icon: LinkIcon,            shortcut: "",            run: () => toast.add({ title: "Link copied" }),        },    ];    const voidInvoice = (id: string) =>        toast.add({            type: "error",            title: `${id} voided`,            description: "It won't be paid in Friday's run.",        });    return (        <div className="flex w-full max-w-xl flex-col gap-2 text-left">            <ul className="flex flex-col overflow-hidden rounded-xl bg-card shadow-border">                {invoices.map((invoice) => (                    <ContextMenu key={invoice.id}>                        <ContextMenuTrigger                            render={<li />}                            className="flex h-11 items-center gap-3 border-b border-border px-4 text-13 last:border-b-0 hover:bg-surface data-popup-open:bg-surface"                        >                            <span className="font-mono text-xs">                                {invoice.id}                            </span>                            <span className="min-w-0 flex-1 truncate text-muted-foreground">                                {invoice.supplier}                            </span>                            <span className="tabular-nums">                                {invoice.amount}                            </span>                            <DropdownMenu>                                <DropdownMenuTrigger                                    render={                                        <Button                                            type="button"                                            variant="ghost"                                            size="icon-xs"                                            aria-label={`More actions for ${invoice.id}`}                                        />                                    }                                >                                    <MoreHorizontalIcon aria-hidden="true" />                                </DropdownMenuTrigger>                                <DropdownMenuContent                                    align="end"                                    className="w-52"                                >                                    {actions(invoice.id).map((action) => (                                        <DropdownMenuItem                                            key={action.label}                                            onClick={action.run}                                        >                                            <action.icon aria-hidden="true" />                                            {action.label}                                            {action.shortcut ? (                                                <DropdownMenuShortcut>                                                    {action.shortcut}                                                </DropdownMenuShortcut>                                            ) : null}                                        </DropdownMenuItem>                                    ))}                                    <DropdownMenuSeparator />                                    <DropdownMenuItem                                        variant="destructive"                                        onClick={() => voidInvoice(invoice.id)}                                    >                                        <Trash2Icon aria-hidden="true" />                                        Void invoice                                    </DropdownMenuItem>                                </DropdownMenuContent>                            </DropdownMenu>                        </ContextMenuTrigger>                        <ContextMenuContent className="w-52">                            {actions(invoice.id).map((action) => (                                <ContextMenuItem                                    key={action.label}                                    onClick={action.run}                                >                                    <action.icon aria-hidden="true" />                                    {action.label}                                    {action.shortcut ? (                                        <ContextMenuShortcut>                                            {action.shortcut}                                        </ContextMenuShortcut>                                    ) : null}                                </ContextMenuItem>                            ))}                            <ContextMenuSeparator />                            <ContextMenuItem                                variant="destructive"                                onClick={() => voidInvoice(invoice.id)}                            >                                <Trash2Icon aria-hidden="true" />                                Void invoice                            </ContextMenuItem>                        </ContextMenuContent>                    </ContextMenu>                ))}            </ul>            <p className="text-xs text-muted-foreground">                Right-click a row, or use its More actions button.            </p>        </div>    );}

Usage#

Context menu is the menu that opens where you right-click, or long-press on touch, on a row, a card or a canvas node. It is a shortcut for people who expect one, never the only way to an action: every item must also be in a visible menu or button, because a right-click can't be discovered and has no standard key on a Mac. It shares Dropdown menu's items, sizes and motion. It is experimental and not used in the product yet.

When to use

  • On grid rows, kanban cards and flow nodes, mirroring the row's More actions menu.
  • On a column header, for sort, pin and hide, when the same options are in the header's own menu.
  • Where power users work fast with a pointer and expect right-click to act on what's under it.

When not to use

  • As the only home for an action. Put it in a visible menu first. Use Dropdown menu
  • For controls such as fields or switches. Use Popover
  • For commands that apply to the whole app rather than the thing under the pointer. Use Command menu
  • On text people need to select and copy. The trigger turns off text selection. Use Copy row

Mirror a visible menu

A context menu repeats the actions of a menu or buttons people can see, in the same order with the same words. Build both from one list so they can't drift.

Destructive is a tint

Destructive items are Signal Red text with a 10% red tint on highlight, last after a separator, exactly as in Dropdown menu.

Anatomy#

INV-20931Northwind Freight
Duplicate⌘D
Copy link
Void invoice
  1. Trigger area. The row, card or node that listens for right-click and long press. It turns off text selection.
  2. Surface. Opens at the pointer. Popover White, 10px corners, 4px padding, the overlay shadow and ring, at least 9rem wide.
  3. Item. A 28px row with 8px corners, an optional 16px icon and 14px text, highlighted in Menu Hover.
  4. Shortcut. 12px Slate Meta at the end of the row.
  5. Separator. A Hairline between groups, edge to edge.
  6. Destructive item. Signal Red, last in the menu.

Examples#

On a card, with a submenu

Right-click the card to move it to another stage through a radio submenu. On touch, a long press opens the same menu.

HalcyonApprovalINV-20874, $9,612.50, due Oct 14
import {  ContextMenu,  ContextMenuContent,  ContextMenuItem,  ContextMenuRadioGroup,  ContextMenuRadioItem,  ContextMenuSeparator,  ContextMenuSub,  ContextMenuSubContent,  ContextMenuSubTrigger,  ContextMenuTrigger,} from "@oration/canon/components/context-menu";import { Tag, type TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function KanbanCard() {    const stages: { id: string; label: string; color: TagColor }[] = [        { id: "received", label: "Received", color: "gray" },        { id: "approval", label: "Approval", color: "amber" },        { id: "scheduled", label: "Scheduled", color: "blue" },        { id: "paid", label: "Paid", color: "green" },    ];    const [stage, setStage] = React.useState("approval");    const current = stages.find((item) => item.id === stage) ?? stages[0];    return (        <ContextMenu>            <ContextMenuTrigger className="flex w-64 flex-col gap-2 rounded-xl bg-card p-3 text-left shadow-border data-popup-open:shadow-border-hover">                <span className="flex items-center justify-between gap-2">                    <span className="text-13 font-medium">Halcyon</span>                    {current ? (                        <Tag color={current.color} dot>                            {current.label}                        </Tag>                    ) : null}                </span>                <span className="text-xs text-muted-foreground tabular-nums">                    INV-20874, $9,612.50, due Oct 14                </span>            </ContextMenuTrigger>            <ContextMenuContent className="w-52">                <ContextMenuSub>                    <ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>                    <ContextMenuSubContent>                        <ContextMenuRadioGroup                            value={stage}                            onValueChange={(value) => {                                setStage(String(value));                                toast.add({                                    title: `Moved to ${stages.find((item) => item.id === value)?.label}`,                                    description: "INV-20874 from Halcyon.",                                });                            }}                        >                            {stages.map((item) => (                                <ContextMenuRadioItem                                    key={item.id}                                    value={item.id}                                >                                    {item.label}                                </ContextMenuRadioItem>                            ))}                        </ContextMenuRadioGroup>                    </ContextMenuSubContent>                </ContextMenuSub>                <ContextMenuItem                    onClick={() =>                        toast.add({                            title: "Reminder set",                            description:                                "Aisha Bello gets a nudge on Monday, Oct 5.",                        })                    }                >                    Remind approver                </ContextMenuItem>                <ContextMenuSeparator />                <ContextMenuItem                    variant="destructive"                    onClick={() =>                        toast.add({ type: "error", title: "INV-20874 voided" })                    }                >                    Void invoice                </ContextMenuItem>            </ContextMenuContent>        </ContextMenu>    );}

Checkbox and radio items

A grid column header: a labelled radio group for sort, checkbox items for pin and wrap, and a plain item to hide it. The header's own menu would offer the same items.

AmountHighest first
import {  ContextMenu,  ContextMenuCheckboxItem,  ContextMenuContent,  ContextMenuGroup,  ContextMenuItem,  ContextMenuLabel,  ContextMenuRadioGroup,  ContextMenuRadioItem,  ContextMenuSeparator,  ContextMenuTrigger,} from "@oration/canon/components/context-menu";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ColumnHeader() {    const [sort, setSort] = React.useState("desc");    const [pinned, setPinned] = React.useState(false);    const [wrap, setWrap] = React.useState(true);    return (        <ContextMenu>            <ContextMenuTrigger className="flex h-8 w-56 items-center justify-between rounded-md px-2 text-13 font-medium text-muted-foreground shadow-border data-popup-open:bg-muted">                Amount                <span className="text-xs font-normal">                    {sort === "asc" ? "Lowest first" : "Highest first"}                    {pinned ? ", pinned" : ""}                </span>            </ContextMenuTrigger>            <ContextMenuContent className="w-52">                <ContextMenuGroup>                    <ContextMenuLabel>Sort</ContextMenuLabel>                    <ContextMenuRadioGroup                        value={sort}                        onValueChange={(value) => setSort(String(value))}                    >                        <ContextMenuRadioItem value="asc">                            Lowest first                        </ContextMenuRadioItem>                        <ContextMenuRadioItem value="desc">                            Highest first                        </ContextMenuRadioItem>                    </ContextMenuRadioGroup>                </ContextMenuGroup>                <ContextMenuSeparator />                <ContextMenuCheckboxItem                    checked={pinned}                    onCheckedChange={(checked) => setPinned(checked)}                >                    Pin column                </ContextMenuCheckboxItem>                <ContextMenuCheckboxItem                    checked={wrap}                    onCheckedChange={(checked) => setWrap(checked)}                >                    Wrap text                </ContextMenuCheckboxItem>                <ContextMenuSeparator />                <ContextMenuItem                    onClick={() =>                        toast.add({                            title: "Amount hidden",                            description: "Show it again from Display.",                        })                    }                >                    Hide column                </ContextMenuItem>            </ContextMenuContent>        </ContextMenu>    );}

States#

States
StateTreatment
ClosedThe trigger area looks like any row or card.
OpeningGrows from a 0.97 scale at the pointer over 160ms on the house ease-out.
Item highlightedMenu Hover fill; icons take the item's text color.
Destructive highlightedA 10% Signal Red tint (20% in dark).
CheckedCheckbox and radio items show a check at the right edge.
Disabled50% opacity, no pointer events, still reachable by arrow keys.
ClosingFades out toward a 0.95 scale in 110ms. Submenus take 160ms, a gap in the shared timing.

Behavior#

  • Built on Base UI Context Menu. Right-click opens it at the pointer; on touch, a long press does. The browser's own menu is suppressed inside the trigger.
  • Keyboard users can open it with the Menu key or Shift+F10 where the platform supports them. macOS has neither, so the same actions must be in a visible menu.
  • Once open, it behaves like Dropdown menu: arrow keys, Home, End and typeahead move the highlight, Enter runs an item, Right and Left open and close submenus, Escape closes it.
  • Unlike Dropdown menu, it takes no modal, openOnHover or delay props.
  • It opens to the right of the pointer, aligned to its top, and flips near the viewport edge. Content scrolls inside the available height.
  • The trigger sets select-none, so text inside it can't be selected while it is a trigger.

Do and don't#

Do. Build the row's visible menu and its context menu from one list of actions.
Don't. Put an action only in the context menu. Nobody finds it, and keyboard users on a Mac can't open it.
Do. Keep the same order and words as the visible menu, destructive last.
Don't. Reorder the items or rename them in the context menu. People learn one and trip on the other.

Content#

  • Same words as the visible menu, verb first: Duplicate, Copy link, Void invoice.
  • Name the object only when the menu could act on several things, such as a row and its cell.
  • Shortcuts use symbols: ⌘D, ⌫.

Accessibility#

  • The surface is role="menu" and items are menuitem, menuitemcheckbox or menuitemradio, as in Dropdown menu.
  • A context menu is never the only path to an action. Mirror it in a visible, labelled menu button.
  • Focus moves into the menu on open and back to the trigger area on close.
  • Long press on touch opens it, which can clash with text selection and drag. Avoid it on draggable cards unless drag uses a handle.
Keyboard interactions
KeysAction
ShiftF10Opens the menu on the focused element, where the platform supports it.
↓↑Moves the highlight, wrapping at the ends.
EnterRuns the highlighted item.
→Opens the highlighted submenu.
←Closes the submenu.
EscCloses the menu.

Design tokens#

Design tokens
TokenUsed for
--popoverSurface fill
--accentHighlighted item and open submenu trigger
--destructiveDestructive text and its 10% and 20% tints
--muted-foregroundLabels and shortcuts
--borderSeparators
shadow-mdThe overlay shadow
--radius-lg10px surface corners
--radius-md8px item corners

API reference#

ContextMenu

The root.

Other props spread onto Base UI ContextMenu.Root.

Props of ContextMenu
PropTypeDefaultDescription
openbooleanNo defaultControls whether it is open.
defaultOpenbooleanfalseWhether it starts open when uncontrolled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when it opens or closes.
loopFocusbooleantrueWraps the highlight at the ends.
disabledbooleanfalseStops it opening, so the browser menu shows instead.

ContextMenuTrigger

The area that listens for right-click and long press.

Other props spread onto Base UI ContextMenu.Trigger (<div>).

Props of ContextMenuTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender the row or card element itself, such as <li />.
classNamestringNo defaultMerged after select-none.

ContextMenuContent

The surface, with its portal and positioner.

Other props spread onto Base UI Menu.Popup.

Props of ContextMenuContent
PropTypeDefaultDescription
side"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end""right"Which side of the pointer it opens on.
align"start" | "center" | "end""start"Alignment to the pointer.
alignOffsetnumber4Offset along the alignment axis.
sideOffsetnumber0Distance from the pointer.
classNamestringNo defaultSet a width such as w-52.

ContextMenuItem

An action.

Other props spread onto Base UI ContextMenu.Item.

Props of ContextMenuItem
PropTypeDefaultDescription
onClick(event) => voidNo defaultRuns the action. The menu then closes.
variant"default" | "destructive""default"destructive draws it in Signal Red.
insetbooleanfalseIndents the label 28px.
disabledbooleanfalseDims it and stops it running.

ContextMenuCheckboxItem

A toggle with a check at the right.

Other props spread onto Base UI ContextMenu.CheckboxItem.

Props of ContextMenuCheckboxItem
PropTypeDefaultDescription
checkedbooleanNo defaultWhether it is checked, when controlled.
onCheckedChange(checked: boolean, eventDetails) => voidNo defaultCalled when it toggles.
insetbooleanfalseIndents the label 28px.

ContextMenuRadioGroup

Groups radio items that share one value.

Other props spread onto Base UI ContextMenu.RadioGroup.

Props of ContextMenuRadioGroup
PropTypeDefaultDescription
valueanyNo defaultThe selected value, when controlled.
onValueChange(value: any, eventDetails) => voidNo defaultCalled when a radio item is chosen.

ContextMenuRadioItem

One option in a radio group.

Other props spread onto Base UI ContextMenu.RadioItem.

Props of ContextMenuRadioItem
PropTypeDefaultDescription
valueRequiredanyNo defaultThe value it selects.
insetbooleanfalseIndents the label 28px.

ContextMenuSub

The root of a submenu.

Other props spread onto Base UI ContextMenu.SubmenuRoot.

No props of its own.

ContextMenuSubTrigger

The item that opens a submenu, with a trailing chevron.

Other props spread onto Base UI ContextMenu.SubmenuTrigger.

Props of ContextMenuSubTrigger
PropTypeDefaultDescription
insetbooleanfalseIndents the label 28px.

ContextMenuSubContent

The submenu surface, opening to the right.

Other props spread onto ContextMenuContent.

Props of ContextMenuSubContent
PropTypeDefaultDescription
classNamestringNo defaultReplaces the default shadow-lg rather than merging with it.

ContextMenuLabel

A 12px Slate Meta group label.

Other props spread onto Base UI ContextMenu.GroupLabel.

Props of ContextMenuLabel
PropTypeDefaultDescription
insetbooleanfalseIndents it 28px.

ContextMenuGroup

Groups items under a label.

Other props spread onto Base UI ContextMenu.Group.

No props of its own.

ContextMenuSeparator

A Hairline between groups.

Other props spread onto Base UI ContextMenu.Separator.

No props of its own.

ContextMenuShortcut

A shortcut at the end of an item.

Other props spread onto <span>.

No props of its own.

ContextMenuPortal

The portal, for custom compositions. ContextMenuContent already includes it.

Other props spread onto Base UI ContextMenu.Portal.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Context menu isn't used anywhere in the product. Grid rows, kanban cards and flow nodes offer their actions only through visible menus today.

ContextMenuSubContent passes className="shadow-lg" before spreading props, so any className you pass, such as a width, replaces the shadow instead of adding to it.

globals.css times context-menu-sub-content at 160ms to open but leaves it out of the 110ms close rule, so context submenus close slower than every other menu.

ContextMenuShortcut has the same tracking-widest letter-spacing as the dropdown shortcut.