Context menu
The right-click menu for rows, cards and canvas nodes, mirroring a visible menu.
- Status
- Experimental
- Level
- Molecule
- 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
Destructive is a tint
Anatomy#
- Trigger area. The row, card or node that listens for right-click and long press. It turns off text selection.
- Surface. Opens at the pointer. Popover White, 10px corners, 4px padding, the overlay shadow and ring, at least 9rem wide.
- Item. A 28px row with 8px corners, an optional 16px icon and 14px text, highlighted in Menu Hover.
- Shortcut. 12px Slate Meta at the end of the row.
- Separator. A Hairline between groups, edge to edge.
- 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.
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.
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#
| State | Treatment |
|---|---|
| Closed | The trigger area looks like any row or card. |
| Opening | Grows from a 0.97 scale at the pointer over 160ms on the house ease-out. |
| Item highlighted | Menu Hover fill; icons take the item's text color. |
| Destructive highlighted | A 10% Signal Red tint (20% in dark). |
| Checked | Checkbox and radio items show a check at the right edge. |
| Disabled | 50% opacity, no pointer events, still reachable by arrow keys. |
| Closing | Fades 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,openOnHoverordelayprops. - 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#
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 aremenuitem,menuitemcheckboxormenuitemradio, 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.
| Keys | Action |
|---|---|
| ShiftF10 | Opens the menu on the focused element, where the platform supports it. |
| ↓↑ | Moves the highlight, wrapping at the ends. |
| Enter | Runs the highlighted item. |
| → | Opens the highlighted submenu. |
| ← | Closes the submenu. |
| Esc | Closes the menu. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Surface fill |
--accent | Highlighted item and open submenu trigger |
--destructive | Destructive text and its 10% and 20% tints |
--muted-foreground | Labels and shortcuts |
--border | Separators |
shadow-md | The overlay shadow |
--radius-lg | 10px surface corners |
--radius-md | 8px item corners |
API reference#
ContextMenu
The root.
Other props spread onto Base UI ContextMenu.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controls whether it is open. |
defaultOpen | boolean | false | Whether it starts open when uncontrolled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when it opens or closes. |
loopFocus | boolean | true | Wraps the highlight at the ends. |
disabled | boolean | false | Stops 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render the row or card element itself, such as <li />. |
className | string | No default | Merged after select-none. |
ContextMenuContent
The surface, with its portal and positioner.
Other props spread onto Base UI Menu.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
alignOffset | number | 4 | Offset along the alignment axis. |
sideOffset | number | 0 | Distance from the pointer. |
className | string | No default | Set a width such as w-52. |
ContextMenuItem
An action.
Other props spread onto Base UI ContextMenu.Item.
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | (event) => void | No default | Runs the action. The menu then closes. |
variant | "default" | "destructive" | "default" | destructive draws it in Signal Red. |
inset | boolean | false | Indents the label 28px. |
disabled | boolean | false | Dims it and stops it running. |
ContextMenuCheckboxItem
A toggle with a check at the right.
Other props spread onto Base UI ContextMenu.CheckboxItem.
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | No default | Whether it is checked, when controlled. |
onCheckedChange | (checked: boolean, eventDetails) => void | No default | Called when it toggles. |
inset | boolean | false | Indents the label 28px. |
ContextMenuRadioGroup
Groups radio items that share one value.
Other props spread onto Base UI ContextMenu.RadioGroup.
| Prop | Type | Default | Description |
|---|---|---|---|
value | any | No default | The selected value, when controlled. |
onValueChange | (value: any, eventDetails) => void | No default | Called when a radio item is chosen. |
ContextMenuRadioItem
One option in a radio group.
Other props spread onto Base UI ContextMenu.RadioItem.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | any | No default | The value it selects. |
inset | boolean | false | Indents 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.
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | false | Indents the label 28px. |
ContextMenuSubContent
The submenu surface, opening to the right.
Other props spread onto ContextMenuContent.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Replaces the default shadow-lg rather than merging with it. |
ContextMenuLabel
A 12px Slate Meta group label.
Other props spread onto Base UI ContextMenu.GroupLabel.
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | false | Indents 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.