Context Menu
The same menu vocabulary opened by right-click or long press, positioned at the pointer rather than against a trigger.
- Status
- Since
0.1.0- Accessibility pattern
- APG menu
Last updated
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-menuInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/context-menuRun 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>;Anatomy
Examples
Composition
ContextMenu
├── ContextMenuTrigger
└── ContextMenuContent
├── ContextMenuGroup
│ ├── ContextMenuLabel
│ ├── ContextMenuItem
│ └── ContextMenuItem
├── ContextMenuSeparator
├── ContextMenuGroup
│ ├── ContextMenuLabel
│ └── ContextMenuCheckboxItem
├── ContextMenuSeparator
├── ContextMenuGroup
│ ├── ContextMenuLabel
│ └── ContextMenuRadioGroup
│ ├── ContextMenuRadioItem
│ └── ContextMenuRadioItem
└── ContextMenuSub
├── ContextMenuSubTrigger
└── ContextMenuSubContent
└── ContextMenuGroup
├── ContextMenuItem
└── ContextMenuItemBasic
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.
Submenu
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.
Shortcuts
ContextMenuShortcut shows the key hint at the inline end of the row. It renders text; binding the
key is the application's job.
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.
Icons
A leading lucide-react icon is sized by the row itself, so pass a className rather than a
size prop.
Checkboxes
ContextMenuCheckboxItem renders role="menuitemcheckbox" with aria-checked. Use
defaultChecked for an uncontrolled row and checked/onCheckedChange for a controlled one.
Radio
ContextMenuRadioGroup holds the value; each ContextMenuRadioItem is a role="menuitemradio",
and selecting one clears the rest of its group.
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.
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.
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.
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.
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).
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | — | |
variant | "default" | "destructive" | — |
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | — |
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"ofmenuitem,menuitemcheckboxandmenuitemradiorows 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
ContextMenuContentre-applies the theme scope on the positioner inside the portal andContextMenuPortalre-applies it on adisplay: contentswrapper (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).
| Contract | States tested |
|---|---|
| Behaviour | default, open, closed, highlighted, checked, disabled, submenu-open |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, described, disabled |
| Visual | default, open, closed, highlighted, checked, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/context-menu.patch. Every hunk:
- OVL-13 — the same two hosts
dropdown-menuhas:ContextMenuContent's own portal is scoped on its Positioner, and the exportedContextMenuPortalscopes adisplay: contentswrapper. - OVL-19 —
ContextMenuContenttakesw-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-defaultis removed from the item, the submenu trigger, the checkbox item and the radio item. - FRM-4 —
data-disabled:pointer-events-noneis dropped from the item, the checkbox item and the radio item, so a disabled row stays hoverable. - BRD-1 —
ContextMenuContentdraws a real 1pxborder border-borderinstead of upstream'sring-1 ring-foreground/10box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves. - A11Y-13 — a focused destructive
ContextMenuItemreadsfocus:text-destructive-texton itsbg-destructive/10wash. 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,ContextMenuCheckboxItemandContextMenuRadioItemeach provide Item'sItemDescriptionContextand pointaria-describedbyat anItemDescriptiononce 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 ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header; no decision of their own.