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.
Playground
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 asChild>
<div>Right-click anywhere in this region</div>
</ContextMenuTrigger>
<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 an Apple keyboard, 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.
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. Apple's HIG is unambiguous: a context menu must never be the only way to reach a command. The Nielsen Norman Group says the same. 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 asChild:
<ContextMenuTrigger asChild>
<table>
<tbody>{/* … */}</tbody>
</table>
</ContextMenuTrigger>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-xl |
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 (12px 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. Apple's HIG puts destructive commands at the end; 40 of the surveyed apps render them red. Use
intent="error". - Keep it short. HIG asks for "a small number" of items and no more than about three separator-delimited groups; Carbon caps a context menu at 12 items before it scrolls.
- One level of submenu. Apple, Material, Fluent and Carbon all say the same. 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 (Primer).
- "…" means a dialog follows.
Rename…opens something;Renameacts immediately. - Hide what is never available; dim what is temporarily unavailable. HIG says hide rather than dim, but Fluent and Primer require unavailable rows to stay focusable so screen-reader users can find out they exist. 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 — Primer and Fluent both forbid it. 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 ContextMenuContent (or wrap the page in a dir="rtl" container). The icon column, shortcut column, indicator gutter and submenu side all mirror.
<ContextMenuContent dir="rtl">
<ContextMenuItem leadingIcon={<Copy />}>نسخ</ContextMenuItem>
</ContextMenuContent>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. The binding follows React Aria, which gives its long-press menu triggers the same opener. 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.
API Reference
ContextMenu (root)
Base UI's ContextMenu.Root. Takes open, defaultOpen, onOpenChange, onOpenChangeComplete, actionsRef, disabled, loopFocus (default true), highlightItemOnHover (default true), modal.
ContextMenuTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render the child directly instead of wrapping it in a <div> |
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" | "ltr" | Reading direction; stamped on the popup element |
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" | 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.