DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
PopoverHoverCardDropdown MenuContextMenuModalAlertDialogDrawerAction Sheet
AI agentsContributing
  1. Components
  2. ContextMenu

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/ui

When 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  ContextMenu,
  ContextMenuTrigger,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuLinkItem,
  ContextMenuSwitchItem,
  ContextMenuLabel,
  ContextMenuGroup,
  ContextMenuHeader,
  ContextMenuFooter,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuCheckboxItem,
  ContextMenuRadioGroup,
  ContextMenuRadioItem,
  ContextMenuSub,
  ContextMenuSubTrigger,
  ContextMenuSubContent,
} from "@tessinaui/ui";
example.tsx
<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 pointerThe menu should be anchored to a visible control
Examples: file explorer, canvas layers, table rows, editor linesExamples: 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

example.tsx
<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:

example.tsx
<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:

example.tsx
<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.

example.tsx
<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:

ValuePopupItems
"none"rounded-nonerounded-none
"sm"rounded-mdrounded-sm
"md"rounded-lgrounded-md
"lg"rounded-xlrounded-lg
"full"rounded-3xlrounded-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:

example.tsx
<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: rounded used to default to "md", which pinned every menu at an 8px radius. Unset now means the family default — 24px at md — matching the DropdownMenu beside it. Pass rounded="md" to keep the old silhouette.

Width

ValueBehaviour
"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:

PropTypeDescription
leadingIconReactNodeIcon — or avatar, or thumbnail — on the start side
trailingIconReactNodeIcon on the end side
shortcutstring | string[]Key hint. ["mod", "K"] resolves per platform; a literal "⌘K" also works but ships a ⌘ to Windows
badgeReactNodeStatus pill beside the label — "New", "Beta", a count
descriptionstringSecondary line below the label
intent"none" | "error""error" styles destructive items
insetbooleanIndent to align with rows that have indicators
disabledbooleanDims 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; Rename acts 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/menuitem role pairing. If the list needs filtering, that is Command.

Sub-menus

example.tsx
<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.

example.tsx
<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

PropTypeDefaultDescription
renderReactElement—Render this element as the region instead of the default <div>. It must accept event handlers and a ref
focusablebooleantrueGive 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

PropTypeDefaultDescription
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
sideOffsetnumber-5Offset from the pointer anchor
alignOffsetnumber2Cross-axis offset from the pointer
collisionPaddingnumber8Minimum gap kept from every viewport edge
collisionAvoidanceobject—Base UI collision behaviour; { side: "none" } for static previews
positionerClassNamestring—Classes on the positioner — the escape hatch for raising z-index, since the positioner's transform traps one set on the popup
portalbooleantrueRender into a portal at document.body
containerHTMLElement | nulldocument.bodyPortal target
inertbooleanfalseRender 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

PropTypeDefaultDescription
size / width / rounded——Match the menu being replaced
sectionsContextMenuSkeletonSection[]—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 / falseSingle-section shorthand
labelstring"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 are menuitem, menuitemcheckbox (checkbox and switch rows) or menuitemradio. Groups are role="group" named by their ContextMenuLabel.
  • 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+F10 does nothing there, and VO+Shift+M opens 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 contextmenu for 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. Pass focusable={false} when it already wraps something focusable; a contextmenu from 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 contextmenu at 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.

KeyBehaviour
↓ / ↑Next / previous item, wrapping
Home / EndFirst / last item
→ / ←Open / close a submenu — reversed in RTL
EnterActivate the row, or open the submenu and focus its first item
SpaceActivate. On checkbox, radio and switch rows: toggle without closing
Printable charactersTypeahead — jumps to the next row whose label matches
EscClose the menu containing focus
TabMove focus out and close every menu
  • Checkbox, radio, switch and link rows stay open when clicked; plain items close. This matches what Space is 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.
Dropdown MenuModal

On this page

When to useWhen not to useInstallationUsageExamplesDefaultIcons & shortcutsItem featuresTable rowsLink itemsSubmenusCheckbox & radio itemsSwitch itemsHeader & footerScrollingSizesOn-colorRTLLoadingOne component, two namesWhen to use ContextMenu vs. DropdownMenuAnatomyTrigger areaTouch & long-pressSizeRoundedCurving your own contentWidthItemsWriting the menuSub-menusRTLAPI ReferenceContextMenu (root)ContextMenuTriggerContextMenuContentContextMenuSkeletonSub-componentsAccessibility