Context Menu
A right-click menu of actions with checkbox, radio and submenu items, opened at the pointer, built on the Radix ContextMenu primitive.
Preview
Onboarding · Step 2
Frame 1440 × 900 · Priya editing
Right-click the canvas, or focus it and pressShift F10
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/context-menu.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| onOpenChange | (open: boolean) => void | - | On ContextMenu. Called when the menu opens (right-click or long-press) or closes. There is no open prop. |
| modal | boolean | true | On ContextMenu. Blocks interaction outside the open menu. |
| disabled | boolean | false | On ContextMenuTrigger. Lets the browser's native menu show instead. |
| variant | "default" | "danger" | "default" | On ContextMenuItem. Danger tints the row for destructive actions. |
| inset | boolean | false | On ContextMenuItem, ContextMenuLabel and ContextMenuSubTrigger. Aligns text with checkbox and radio rows. |
| onSelect | (event: Event) => void | - | On items. Call event.preventDefault() to keep the menu open. |
| checked / onCheckedChange | boolean, (checked: boolean) => void | - | On ContextMenuCheckboxItem (controlled only). |
| value / onValueChange | string, (value: string) => void | - | On ContextMenuRadioGroup (controlled only). |
| className | string | - | Merged last on every part, so it wins. |
Build notes
Built on the Radix ContextMenu primitive, which anchors a virtual element at the pointer coordinates of the `contextmenu` event (and a 700 ms long-press on touch) and otherwise shares the DropdownMenu internals: focus management, typeahead, roving highlight, submenu pointer-grace and the `--radix-context-menu-content-*` variables. It is deliberately visually and API-identical to this library's `menu`: the same part set and prop names, the same `row` and `panel` class strings, the same inline check and chevron SVGs, the same radio dot and the same `danger` variant. Those strings are copied into the file rather than imported from `menu`, so `context-menu` installs on its own. `ContextMenuContent` and `ContextMenuSubContent` are wrapped in a Portal. The panel surface is described in the Pebble paragraph below; height is capped by `--radix-context-menu-content-available-height` and it scrolls vertically; `transform-origin` comes from `--radix-context-menu-content-transform-origin`. Animation reuses the `menu-in` (opacity 0, scale .95 to 1; `--motion-base`, enter easing) and `menu-out` (reverse; `--motion-fast`, exit easing) keyframes, shipped again in this item's registry `css` with identical bodies so installing both items is harmless; they are keyed on `data-state` under `motion-safe:`, and Radix's presence waits for `menu-out` before unmounting. Checkbox and radio rows add `pl-8` and an absolutely positioned indicator slot; the chevron flips with `rtl:rotate-180`. Unlike the source: `danger` replaces "destructive", the label is small muted text (matching `menu`), the indicator slot is `size-4`, and checkbox items are controlled only.
**Pebble (2026-11-03).** Restyled in lockstep with `menu` (same copied `row`, `rowIcons` and `panel` strings). The panel is a raised Pebble surface: `bg-card`, `ring-1 ring-hairline`, `inset-shadow-highlight`, `rounded-lg`, no border; `shadow-raised-md` on `ContextMenuContent` and `shadow-raised-lg` on `ContextMenuSubContent`. Rows are `min-h-8` with a `bg-muted` + `text-foreground` roving highlight eased over `--motion-fast`; row icons go from muted to ink with the highlight. Ember is reserved for the indicators: `text-accent` check, and a 6 px ember dot with a `ring-accent/20` halo for radio items (plus `forced-colors:bg-[CanvasText]`, since forced colors drop backgrounds and rings). Labels are 12 px muted, shortcuts 11 px mono `tabular-nums`. The submenu chevron nudges 2 px toward the open submenu (mirrored in RTL, off under reduced motion). `menu-in`/`menu-out` keep their names and bodies but now run on tokens (`--motion-base` + `--motion-enter` in, `--motion-fast` + `--motion-exit` out). The demo is a design-canvas card: a recessed `bg-muted` dotted canvas drawn in SVG (an artboard, a selected shape with ember handles, a collaborator cursor) that is the focusable trigger (`tabIndex={0}`, `role="group"`, labelled) so Shift+F10 opens the menu from the keyboard; the menu shows icons, shortcuts, a submenu with a disabled row, a disabled item, two checkbox items, a radio group and a danger item.
<!-- PEBBLE-LICENSE -->View source
registry/overlay/context-menu/context-menu.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: context-menu.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
// Pebble rows, kept identical to the Menu component's (copied, not imported,
// so this file installs on its own): compact 32 px rows, a muted roving
// highlight with ink text, icons that brighten from muted to ink with it.
const row =
"relative flex min-h-8 cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none transition-colors duration-(--motion-fast) ease-enter motion-reduce:transition-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 forced-colors:focus:outline-2 forced-colors:focus:outline-solid forced-colors:focus:outline-[Highlight]";
const rowIcons =
"[&_svg:not([class*='text-'])]:text-muted-foreground focus:[&_svg:not([class*='text-'])]:text-foreground data-[state=open]:[&_svg:not([class*='text-'])]:text-foreground";
// The floating panel: a raised Pebble surface (hairline ring, 1 px top light;
// elevation is added per panel: md for the menu, lg for submenus). It scales
// in from the pointer (menu-in, 0.95 to 1) over --motion-base on the enter
// curve and fades out over --motion-fast on the exit curve; both run only
// under motion-safe and are keyed on data-state, so Radix's presence waits
// for the exit keyframe to finish.
const panel =
"z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-lg bg-card p-1 text-card-foreground ring-1 ring-hairline inset-shadow-highlight forced-colors:border forced-colors:border-[CanvasText] motion-safe:data-[state=closed]:animate-[menu-out_var(--motion-fast)_var(--motion-exit)] motion-safe:data-[state=open]:animate-[menu-in_var(--motion-base)_var(--motion-enter)]";
const indicator =
"pointer-events-none absolute left-2 flex size-4 items-center justify-center text-accent";
export function ContextMenu(props: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
export function ContextMenuTrigger(
props: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>,
) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
}
export function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
panel,
"max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) shadow-raised-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
export function ContextMenuGroup(props: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
export type ContextMenuItemProps = React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
variant?: "default" | "danger";
inset?: boolean;
};
export function ContextMenuItem({
variant = "default",
inset,
className,
...props
}: ContextMenuItemProps) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-variant={variant}
data-inset={inset || undefined}
className={cn(
row,
rowIcons,
"data-inset:pl-8",
// Danger keeps the strong text tone so it holds contrast on the card
// and on the tinted focus background.
"data-[variant=danger]:text-danger-strong data-[variant=danger]:focus:bg-danger/10 data-[variant=danger]:focus:text-danger-strong data-[variant=danger]:*:[svg]:text-danger-strong!",
className,
)}
{...props}
/>
);
}
export function ContextMenuCheckboxItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(row, "pl-8", className)}
{...props}
>
<span data-slot="context-menu-item-indicator" className={indicator}>
<ContextMenuPrimitive.ItemIndicator>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-4"
>
<path d="m3.5 8.5 3 3 6-7" />
</svg>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
export function ContextMenuRadioGroup(
props: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>,
) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
export function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(row, "pl-8", className)}
{...props}
>
<span data-slot="context-menu-item-indicator" className={indicator}>
<ContextMenuPrimitive.ItemIndicator className="flex">
{/* Ember dot with a soft halo: the selected thing, small. */}
<span className="size-1.5 rounded-full bg-current ring-[3px] ring-accent/20 forced-colors:bg-[CanvasText]" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
export function ContextMenuLabel({
inset,
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & { inset?: boolean }) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset || undefined}
className={cn(
"px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground data-inset:pl-8",
className,
)}
{...props}
/>
);
}
export function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
export function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto pl-4 font-mono text-[11px] tracking-wide text-muted-foreground tabular-nums",
className,
)}
{...props}
/>
);
}
export function ContextMenuSub(props: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
export function ContextMenuSubTrigger({
inset,
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { inset?: boolean }) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset || undefined}
className={cn(
row,
rowIcons,
"group/sub data-inset:pl-8 data-[state=open]:bg-muted data-[state=open]:text-foreground",
className,
)}
{...props}
>
{children}
{/* The chevron leans toward the submenu while it is open. */}
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="ml-auto size-4 transition-transform duration-(--motion-fast) ease-enter group-data-[state=open]/sub:translate-x-0.5 motion-reduce:transition-none rtl:rotate-180 rtl:group-data-[state=open]/sub:-translate-x-0.5"
>
<path d="m6 4 4 4-4 4" />
</svg>
</ContextMenuPrimitive.SubTrigger>
);
}
export function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
panel,
"origin-(--radix-context-menu-content-transform-origin) shadow-raised-lg",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
