ContextMenu
A right-click / long-press menu anchored at the pointer. Shares DropdownMenu's popup family verbatim — the same rows, sizes, radius cascade and accessibility contract — so the two components differ only in how they are invoked.
pnpm add @tessinaui/uiWhen to use
- Secondary actions on an item the user points at: a file, a table row, a layer, a line of text. The menu opens at the pointer, so it reads as being about that item.
- A short list of commands that act on what was clicked, with the destructive one last and
intent="error". - Decluttering repeated rows, where a visible menu button on every row would add noise. Pair it with a visible route to the same commands.
- Desktop-style interaction that also opens by long-press on touch and from the keyboard (Menu key, Shift+F10, or Alt+ArrowDown).
When not to use
- As the only way to reach a command. A right-click or long-press is invisible until tried. Offer the same action somewhere visible, or use a DropdownMenu on a visible button.
- When the menu belongs to a control. If the actions are global or hang off a button, use DropdownMenu, anchored to that control.
- For a long list or one that opens with a preview. Use an ActionSheet, especially on touch.
- To filter or search a list. A menu holds menu items only, no inputs. Use Command.
- For a single action. Put it on the page as a button.
Installation
pnpm add @tessinaui/uiUsage
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuSwitchItem,
ContextMenuLabel,
ContextMenuGroup,
ContextMenuHeader,
ContextMenuFooter,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuCheckboxItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
} from "@tessinaui/ui";<ContextMenu>
<ContextMenuTrigger render={<div>Right-click anywhere in this region</div>} />
<ContextMenuContent>
<ContextMenuItem leadingIcon={<Copy />} shortcut={["mod", "C"]}>Copy</ContextMenuItem>
<ContextMenuItem leadingIcon={<Scissors />} shortcut={["mod", "X"]}>Cut</ContextMenuItem>
<ContextMenuItem leadingIcon={<Edit />}>Rename…</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem leadingIcon={<Trash2 />} intent="error" shortcut={["mod", "Backspace"]}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>Examples
Default
The simplest usage — right-click the region to open a menu of plain items.
Icons & shortcuts
Leading icons and a shortcut column. shortcut takes key tokens, and "mod" resolves per platform — ⌘ on macOS and iOS, Ctrl everywhere else.
Item features
Everything one row can carry: description, badge, avatar as the leading visual, disabled, and the destructive row.
Table rows
The canonical desktop use — one menu per row, with the right-clicked row staying visibly selected while its menu is up.
Link items
ContextMenuLinkItem renders a real <a href>, so "Open in new tab" keeps middle-click, cmd-click and copy-link-address.
Submenus
Nest a ContextMenuSub to group related actions behind an expandable trigger.
Checkbox & radio items
Stateful selections that share the indicator gutter with plain rows, so every label starts on one edge.
Switch items
A switch to look at, a menuitemcheckbox to assistive tech — for settings-shaped rows.
Header & footer
An item-preview band pinned above the commands, an action pinned below, and an optional backdrop.
Scrolling
A menu taller than the space below the cursor scrolls instead of being cut off.
Sizes
The size prop scales item padding, font size, gap and the popup's own frame from xs to xl.
On-color
On a coloured Surface the trigger region draws its focus ring in the plate's ink over a gap of the plate — tone resolves from context, no prop needed. The menu stays a standard panel: a portal floats above the plate, it is not on it, so its rows, shortcuts and destructive item read exactly as they do on the page.
RTL
dir="rtl" mirrors the whole menu — icon column, shortcut column, indicator and submenu side.
Loading
ContextMenuSkeleton mirrors the menu shape while content loads. Pass sections to match a grouped menu.
One component, two names
ContextMenu and DropdownMenu are the same popup. Every row part — ContextMenuItem, ContextMenuCheckboxItem, ContextMenuSubTrigger and the rest — is its DropdownMenu* counterpart, re-exported under a second name, exactly as Menubar composes it.
This is not a convenience: Base UI's context-menu parts are the menu parts. Only Root and Trigger are context-menu-specific, and the positioner detects a context-menu parent and swaps in the pointer anchor by itself. So anything fixed in a menu row lands in all three components at once — which is the opposite of what happened while this component kept a private copy of the same code and quietly re-grew a dozen bugs the dropdown had already fixed.
Practically: anything you can do in a DropdownMenu row, you can do here, and the two menus are guaranteed to look identical side by side.
When to use ContextMenu vs. DropdownMenu
| Use ContextMenu when… | Use DropdownMenu when… |
|---|---|
| The menu is invoked by right-click (or long-press on touch) | The menu is invoked by clicking a button |
| Actions apply to whatever the user clicked on (a row, file, region) | Actions are global / unrelated to a specific element |
| The menu should appear at the pointer | The menu should be anchored to a visible control |
| Examples: file explorer, canvas layers, table rows, editor lines | Examples: avatar menu, settings dropdown, toolbar overflow |
In a survey of ~140 shipping apps, the split was almost even — 12 apps opened menus at the pointer, 17 snapped them to a kebab button — and several shipped both on the same row. The two are complements, not alternatives.
Discoverability is the real tradeoff. A context menu must never be the only way to reach a command: a hidden gesture is not an affordance. Ten of the surveyed apps ship a dedicated coach mark teaching the long-press gesture — if it needs teaching, it needs a visible alternative too.
Anatomy
<ContextMenu>
<ContextMenuTrigger>…</ContextMenuTrigger>
<ContextMenuBackdrop /> {/* optional scrim */}
<ContextMenuContent>
<ContextMenuHeader /> {/* sticky preview band */}
<ContextMenuGroup>
<ContextMenuLabel />
<ContextMenuItem />
<ContextMenuLinkItem />
<ContextMenuSwitchItem />
<ContextMenuCheckboxItem />
</ContextMenuGroup>
<ContextMenuRadioGroup>
<ContextMenuRadioItem />
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger />
<ContextMenuSubContent />
</ContextMenuSub>
<ContextMenuFooter /> {/* sticky action band */}
</ContextMenuContent>
</ContextMenu>Put a ContextMenuLabel inside a ContextMenuGroup. The group is what carries aria-labelledby; a label on its own names nothing but itself.
Trigger area
ContextMenuTrigger renders a <div>. The menu opens on right-click anywhere inside that region, or on long-press on touch. To use an existing element as the trigger, pass it as render:
<ContextMenuTrigger
render={
<table>
<tbody>{/* … */}</tbody>
</table>
}
/>While the menu is open the trigger carries data-popup-open and data-pressed, so the element that was right-clicked can stay visibly selected — one variant class, no state to track:
<div className="hover:bg-secondary/60 data-[popup-open]:bg-secondary">Touch & long-press
Long-press is handled by the primitive — a 500ms press that cancels if the finger moves, so it does not fight scrolling or dragging. There is nothing to wire up and no timing prop.
When to use an ActionSheet instead. Of the mobile apps surveyed, 25 presented these menus as bottom sheets and 24 kept them anchored to the item — an even enough split that a viewport breakpoint would be the wrong way to decide, which is why this component does not swap itself. The corpus divides on content, not screen size:
- Anchored menu (this component) — a short list of 3–6 commands acting on the item under the finger.
- ActionSheet — a long command list, or one that opens with a preview of the item, or anything that wants a grab handle and a Cancel row.
Rows are ≥44px on phones at every size, and the popup clamps itself to the viewport, so an anchored menu is safe on a small screen — the sheet is an editorial choice, not a fix.
Size
size (xs | sm | md | lg | xl, default md) scales item padding, type, gap and the popup's own frame. It is the menu family's ladder, shared with DropdownMenu and Menubar.
<ContextMenuContent size="sm">…</ContextMenuContent>Rounded
rounded is optional. Left unset, the popup and its items take the size-derived radii the menu family uses, so a context menu and a dropdown at the same size are identical. Set it to override both at once:
| Value | Popup | Items |
|---|---|---|
"none" | rounded-none | rounded-none |
"sm" | rounded-md | rounded-sm |
"md" | rounded-lg | rounded-md |
"lg" | rounded-xl | rounded-lg |
"full" | rounded-3xl | rounded-2xl |
ContextMenuSubContent inherits the same value via context, so submenu corners match the parent.
Curving your own content
Content you put inside a menu — a thumbnail in a header band, a swatch, a preview tile — should curve like the rows around it, or it reads as pasted on. The popup publishes its item radius as a CSS variable:
<img className="rounded-[var(--menu-item-radius)]" />It tracks whatever the menu is actually doing: the size-derived radius when rounded is unset (16px at md), the explicit step when it is set, and 0 at rounded="none" — so your image squares off exactly when the popup does.
Changed:
roundedused to default to"md", which pinned every menu at an 8px radius. Unset now means the family default — 24px atmd— matching theDropdownMenubeside it. Passrounded="md"to keep the old silhouette.
Width
| Value | Behaviour |
|---|---|
"narrow" | caps at 12rem |
"default" | bounded by the size's own minimum |
"wide" | at least 20rem — for descriptions and preview bands |
Every one of these is clamped against the viewport (min(…, calc(100dvw - 2rem))), so no width can push the menu off a 320px screen.
Items
ContextMenuItem accepts:
| Prop | Type | Description |
|---|---|---|
leadingIcon | ReactNode | Icon — or avatar, or thumbnail — on the start side |
trailingIcon | ReactNode | Icon on the end side |
shortcut | string | string[] | Key hint. ["mod", "K"] resolves per platform; a literal "⌘K" also works but ships a ⌘ to Windows |
badge | ReactNode | Status pill beside the label — "New", "Beta", a count |
description | string | Secondary line below the label |
intent | "none" | "error" | "error" styles destructive items |
inset | boolean | Indent to align with rows that have indicators |
disabled | boolean | Dims and disables the row |
ContextMenuCheckboxItem, ContextMenuRadioItem and ContextMenuSwitchItem take the same vocabulary on top of their own checked / value props. ContextMenuLinkItem adds href, target and rel.
Writing the menu
Rules the surveyed systems agree on, worth following even though nothing enforces them:
- Destructive last, and red. Destructive commands go at the end, out of the path of a fast pointer; 40 of the surveyed apps render them red. Use
intent="error". - Keep it short. A small number of items, and no more than about three separator-delimited groups; past about 12 items the menu scrolls, and a scrolling context menu is a list in the wrong clothes.
- One level of submenu. Deeper nesting is unreachable by touch and unreadable in a screen reader's reading order. Five of the surveyed apps go three deep; none of them are easier for it.
- Icons are all-or-nothing. If one row in a group has no leading icon, drop icons from that group — a ragged label edge is worse than no icons.
- "…" means a dialog follows.
Rename…opens something;Renameacts immediately. - Hide what is never available; dim what is temporarily unavailable. Dimmed rows must stay focusable, or screen-reader users never find out they exist at all. Split the difference by permission (hide) versus state (dim, and say why in the
description). - No form controls in the menu. No inputs, no buttons, no filter field — a menu's children are menu items, and anything else breaks both the roving focus and the
menu/menuitemrole pairing. If the list needs filtering, that is Command.
Sub-menus
<ContextMenuSub>
<ContextMenuSubTrigger leadingIcon={<Share2 />}>Share</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Copy link</ContextMenuItem>
<ContextMenuItem>Email</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>The chevron follows the content's dir — right-pointing in LTR, left-pointing in RTL — and the submenu flips to the other side when there is no room.
RTL
Pass dir="rtl" on ContextMenu (or on ContextMenuContent, which overrides the root's). The icon column, shortcut column, indicator gutter, submenu side and the submenu arrow keys all mirror.
<ContextMenu dir="rtl">
<ContextMenuTrigger>…</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem leadingIcon={<Copy />}>نسخ</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>A dir="rtl" ancestor alone is not enough: the popup portals out of it, and Base UI reads keyboard direction from this prop only.
API Reference
ContextMenu (root)
Base UI's ContextMenu.Root. Takes open, defaultOpen, onOpenChange, onOpenChangeComplete, actionsRef, disabled, loopFocus (default true), highlightItemOnHover (default true), plus dir ("ltr" \| "rtl") — the reading direction for the whole menu, keyboard included. Unset, it inherits the page.
ContextMenuTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | — | Render this element as the region instead of the default <div>. It must accept event handlers and a ref |
focusable | boolean | true | Give the region a tab stop and a focus ring so the Menu key can reach it. Pass false when the region already contains its own focus stop |
Stamps data-popup-open and data-pressed while its menu is open.
ContextMenuContent
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Item and frame density |
rounded | "none" | "sm" | "md" | "lg" | "full" | — | Popup and item radius; unset uses the size-derived family defaults |
width | "narrow" | "default" | "wide" | "default" | Width clamp, always viewport-bounded |
dir | "ltr" | "rtl" | — | Reading direction for this popup, keyboard included. Overrides the root's dir; unset, inherits it |
sideOffset | number | -5 | Offset from the pointer anchor |
alignOffset | number | 2 | Cross-axis offset from the pointer |
collisionPadding | number | 8 | Minimum gap kept from every viewport edge |
collisionAvoidance | object | — | Base UI collision behaviour; { side: "none" } for static previews |
positionerClassName | string | — | Classes on the positioner — the escape hatch for raising z-index, since the positioner's transform traps one set on the popup |
portal | boolean | true | Render into a portal at document.body |
container | HTMLElement | null | document.body | Portal target |
inert | boolean | false | Render as a static, non-interactive illustration |
side, align and arrow are deliberately absent: a pointer anchor is a zero-size rect at the cursor, so there is no trigger edge to flank and nothing for a tail to point at.
ContextMenuSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size / width / rounded | — | — | Match the menu being replaced |
sections | ContextMenuSkeletonSection[] | — | Mirror a grouped menu: { label?, items, icons?, shortcuts?, indicators?, submenu? } per group, separated as the real menu is |
items / showLabel / showIcons / showShortcuts | — | 6 / false / true / false | Single-section shorthand |
label | string | "Loading", Arabic under dir="rtl" | Announced as sr-only text inside the role="status" region |
Rows take their height from the real row's padding and type, so the placeholder→content swap does not shift layout.
Sub-components
ContextMenuPortal, ContextMenuBackdrop, ContextMenuGroup, ContextMenuHeader, ContextMenuFooter, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuItem, ContextMenuLinkItem, ContextMenuSwitchItem, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent — each identical to its DropdownMenu* counterpart, and documented in full on the DropdownMenu page.
Accessibility
- The popup is
role="menu"; rows aremenuitem,menuitemcheckbox(checkbox and switch rows) ormenuitemradio. Groups arerole="group"named by theirContextMenuLabel. - Opens on right-click, long-press on touch, and — because the trigger region is in the tab order — the Menu key or Shift+F10 once it has focus.
- Alt + ↓ opens it too, and is the only way in on macOS: that platform has no Menu key,
Shift+F10does nothing there, andVO+Shift+Mopens the system menu bar rather than the component. It is the conventional opener for long-press menu triggers, so the binding is shared with them. The menu is anchored to the middle of the trigger, exactly where a right-click there would put it. - Focus is a roving tabindex inside the popup; closing returns focus to where it came from.
Why the trigger is focusable. There is no special "open the menu" key: browsers already fire
contextmenufor the Menu key and Shift+F10, but they fire it at the focused element. A plain<div>region — which is what a context-menu trigger normally is — can never receive one, so the menu would be reachable by mouse and by long-press and by nothing else. The region therefore takes a tab stop and a focus ring. Passfocusable={false}when it already wraps something focusable; acontextmenufrom a descendant bubbles up regardless.That covers Windows and Linux. macOS has no Menu key at all, so the region also binds Alt + ↓ and opens by dispatching a real
contextmenuat the trigger's centre — the same door the pointer uses, so there is no second open path to drift. Enter and Space stay unbound: they are the primary-activation contract of whatever the region wraps, and a context menu is a secondary action.
| Key | Behaviour |
|---|---|
↓ / ↑ | Next / previous item, wrapping |
Home / End | First / last item |
→ / ← | Open / close a submenu — reversed in RTL |
Enter | Activate the row, or open the submenu and focus its first item |
Space | Activate. On checkbox, radio and switch rows: toggle without closing |
| Printable characters | Typeahead — jumps to the next row whose label matches |
Esc | Close the menu containing focus |
Tab | Move focus out and close every menu |
- Checkbox, radio, switch and link rows stay open when clicked; plain items close. This matches what
Spaceis expected to do on a checkable row. - Rows are ≥44px on phones (WCAG 2.5.5) at every size.
- Contrast: AA in both themes on every row state, including destructive rows and description lines.
- Always offer a non-context-menu route to critical actions. A context menu is invisible until invoked.