Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsOverlays & feedback

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

64%
92 × 36Malik

Right-click the canvas, or focus it and pressShift F10

Open on the selected button

Button · Primary

DuplicateCtrl D
Group selectionCtrl G
Align
Lock position
Snap to grid
Show rulers

Layer

Above text
Below text
Delete shapeDel

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

Props

PropTypeDefaultDescription
onOpenChange(open: boolean) => void-On ContextMenu. Called when the menu opens (right-click or long-press) or closes. There is no open prop.
modalbooleantrueOn ContextMenu. Blocks interaction outside the open menu.
disabledbooleanfalseOn ContextMenuTrigger. Lets the browser's native menu show instead.
variant"default" | "danger""default"On ContextMenuItem. Danger tints the row for destructive actions.
insetbooleanfalseOn 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 / onCheckedChangeboolean, (checked: boolean) => void-On ContextMenuCheckboxItem (controlled only).
value / onValueChangestring, (value: string) => void-On ContextMenuRadioGroup (controlled only).
classNamestring-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>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗