DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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

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

API Reference

ContextMenu (root)

Base UI's ContextMenu.Root. Takes open, defaultOpen, onOpenChange, onOpenChangeComplete, actionsRef, disabled, loopFocus (default true), highlightItemOnHover (default true), modal.

ContextMenuTrigger

PropTypeDefaultDescription
asChildbooleanfalseRender the child directly instead of wrapping it in a <div>
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""ltr"Reading direction; stamped on the popup element
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"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.

ContainerDate Picker

On this page

PlaygroundInstallationUsageExamplesDefaultIcons & shortcutsItem featuresTable rowsLink itemsSubmenusCheckbox & radio itemsSwitch itemsHeader & footerScrollingSizesRTLLoadingOne component, two namesWhen to use ContextMenu vs. DropdownMenuAnatomyTrigger areaTouch & long-pressSizeRoundedCurving your own contentWidthItemsWriting the menuSub-menusRTLAccessibilityAPI ReferenceContextMenu (root)ContextMenuTriggerContextMenuContentContextMenuSkeletonSub-components