Skip to content
Component installs need the registry setup
VegaStack Design

Context Menu

The same menu vocabulary opened by right-click or long press, positioned at the pointer rather than against a trigger.

Status
stable
Since
0.1.0
Accessibility pattern
APG menu

Last updated

Right click here

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Context Menu before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify context-menu

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/context-menu

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/item.

Usage

import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";

<ContextMenu>
  <ContextMenuTrigger>Right click here</ContextMenuTrigger>
  <ContextMenuContent>
    <ContextMenuItem>Profile</ContextMenuItem>
    <ContextMenuItem>Billing</ContextMenuItem>
    <ContextMenuItem>Team</ContextMenuItem>
    <ContextMenuItem>Subscription</ContextMenuItem>
  </ContextMenuContent>
</ContextMenu>;
Right click here

Anatomy

ContextMenu — data-slot="context-menu"
ContextMenuTrigger — data-slot="context-menu-trigger"
ContextMenuContent — data-slot="context-menu-content"
ContextMenuItem — data-slot="context-menu-item"
ContextMenuCheckboxItem — data-slot="context-menu-checkbox-item"
ContextMenuRadioItem — data-slot="context-menu-radio-item"
ContextMenuLabel — data-slot="context-menu-label"
ContextMenuSeparator — data-slot="context-menu-separator"
ContextMenuShortcut — data-slot="context-menu-shortcut"
ContextMenuGroup — data-slot="context-menu-group"
ContextMenuPortal — data-slot="context-menu-portal"
ContextMenuSub — data-slot="context-menu-sub"
ContextMenuSubContent — data-slot="context-menu-sub-content"
ContextMenuSubTrigger — data-slot="context-menu-sub-trigger"
ContextMenuRadioGroup — data-slot="context-menu-radio-group"

Examples

Composition

ContextMenu
├── ContextMenuTrigger
└── ContextMenuContent
    ├── ContextMenuGroup
    │   ├── ContextMenuLabel
    │   ├── ContextMenuItem
    │   └── ContextMenuItem
    ├── ContextMenuSeparator
    ├── ContextMenuGroup
    │   ├── ContextMenuLabel
    │   └── ContextMenuCheckboxItem
    ├── ContextMenuSeparator
    ├── ContextMenuGroup
    │   ├── ContextMenuLabel
    │   └── ContextMenuRadioGroup
    │       ├── ContextMenuRadioItem
    │       └── ContextMenuRadioItem
    └── ContextMenuSub
        ├── ContextMenuSubTrigger
        └── ContextMenuSubContent
            └── ContextMenuGroup
                ├── ContextMenuItem
                └── ContextMenuItem
Right click here

Basic

The trigger is the surface, not a button: a right-click (or a long press on a coarse pointer) anywhere inside it opens the menu at the pointer.

Right click here

ContextMenuSub nests a second menu behind a ContextMenuSubTrigger, which opens on hover and on the → key. ContextMenuSubContent defaults to side="inline-end", so it mirrors in a right-to-left document.

Right click here

Shortcuts

ContextMenuShortcut shows the key hint at the inline end of the row. It renders text; binding the key is the application's job.

Right click here

Groups

ContextMenuGroup with a ContextMenuLabel names a block of related commands, and ContextMenuSeparator divides the blocks. A long menu reads by its groups before it reads by its rows.

Right click here

Icons

A leading lucide-react icon is sized by the row itself, so pass a className rather than a size prop.

Right click here

Checkboxes

ContextMenuCheckboxItem renders role="menuitemcheckbox" with aria-checked. Use defaultChecked for an uncontrolled row and checked/onCheckedChange for a controlled one.

Right click here

Radio

ContextMenuRadioGroup holds the value; each ContextMenuRadioItem is a role="menuitemradio", and selecting one clears the rest of its group.

Right click here

Destructive

variant="destructive" sets data-variant="destructive", which tints the label, the icon and the focus wash from the destructive token; the focused label reads in the destructive-text ink so it clears AA on the wash.

Right click here

Sides

side on ContextMenuContent fixes which edge of the pointer the popup grows from — top, right, bottom, left, or the logical inline-start/inline-end. The positioner still flips away from a viewport edge.

Right click (top)
Right click (right)
Right click (bottom)
Right click (left)

RTL

Row padding, the check indicator's column and the submenu chevron's rtl:rotate-180 are logical, so a DirectionProvider set to rtl mirrors the whole menu.

Right click here
انقر بزر الماوس الأيمن هنا

Description line

A row that needs a second line composes Item's parts: ItemContent › ItemTitle + ItemDescription. ContextMenuItem, ContextMenuCheckboxItem and ContextMenuRadioItem each provide Item's description context, so the description's id lands on the row's aria-describedby and a screen reader hears it as the row's description (API-19). This is also where a disabled row says why: a disabled item keeps focus and shows its reason as a description; Tooltip is not the reason carrier.

Right click here

API Reference

ContextMenuContent adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

PropTypeDefaultDescription
insetboolean—
variant"default" | "destructive"—
PropTypeDefaultDescription
insetboolean—
PropTypeDefaultDescription
insetboolean—

ContextMenu, ContextMenuTrigger, ContextMenuGroup, ContextMenuSubContent, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub and ContextMenuPortal add no props of their own — each accepts everything the Base UI part accepts, and ContextMenuPortal is a pass-through that re-applies the theme scope.

Accessibility

  • Renders Base UI's ContextMenu: the popup is a role="menu" of menuitem, menuitemcheckbox and menuitemradio rows with type-ahead, ↑/↓, Home/End, →/← for submenus and Esc to close.
  • A context menu is never the only path to a command: the same actions must be reachable from a visible control, because a right-click is not discoverable and not available on every input.
  • The popup portals out of the trigger's subtree, so ContextMenuContent re-applies the theme scope on the positioner inside the portal and ContextMenuPortal re-applies it on a display: contents wrapper (OVL-13).
  • Rows carry the hand cursor from the one global rule in base.css (INT-1).
  • A disabled item keeps focus and shows its reason as a description; Tooltip is not the reason carrier. ↑/↓ land on it and Enter does nothing — Base UI keeps every row focusable when disabled — and its pointer events stay alive too (FRM-4).
ContractStates tested
Behaviourdefault, open, closed, highlighted, checked, disabled, submenu-open
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, described, disabled
Visualdefault, open, closed, highlighted, checked, disabled

Do / Don't

Do
Use a context menu as a shortcut to commands that are also reachable from a visible control.
Don't
Hide an action behind right-click alone — it is undiscoverable, and unavailable to anyone without a secondary pointer button.

Deviations

Upstream's file plus packages/ui/upstream/patches/context-menu.patch. Every hunk:

  • OVL-13 — the same two hosts dropdown-menu has: ContextMenuContent's own portal is scoped on its Positioner, and the exported ContextMenuPortal scopes a display: contents wrapper.
  • OVL-19 — ContextMenuContent takes w-max max-w-(--available-width), so the menu is as wide as its longest item and an item never wraps near a viewport edge.
  • INT-1 — cursor-default is removed from the item, the submenu trigger, the checkbox item and the radio item.
  • FRM-4 — data-disabled:pointer-events-none is dropped from the item, the checkbox item and the radio item, so a disabled row stays hoverable.
  • BRD-1 — ContextMenuContent draws a real 1px border border-border instead of upstream's ring-1 ring-foreground/10 box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves.
  • A11Y-13 — a focused destructive ContextMenuItem reads focus:text-destructive-text on its bg-destructive/10 wash. Upstream inks it with the fill (focus:text-destructive), which measures 3.99:1 on its own tint in light, under the AA floor. The resting ink measures 4.77:1 in light and 6.21:1 in dark on the popup, over the floor, so it and the icon ink stay upstream's.
  • API-19 — ContextMenuItem, ContextMenuCheckboxItem and ContextMenuRadioItem each provide Item's ItemDescriptionContext and point aria-describedby at an ItemDescription once one registers inside the row, so a two-line row reads its second line as the row's accessible description (Description line). A one-line row is unchanged.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header; no decision of their own.

On this page