# Context Menu

> Right-click menus built on the Dropdown Menu parts.

Category: Overlay. Docs: https://neonkit.heyaadi.com/components/context-menu

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/context-menu.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/context-menu`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Dropdown Menu](https://neonkit.heyaadi.com/components/dropdown-menu.md). The CLI installs them too.

## Usage

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

<ContextMenu>
  <ContextMenuTrigger className="h-40 rounded-xl border border-white/10">
    Right-click here
  </ContextMenuTrigger>
  <ContextMenuContent>
    <ContextMenuItem shortcut="⌘D">Duplicate</ContextMenuItem>
    <ContextMenuItem variant="danger">Move to trash</ContextMenuItem>
  </ContextMenuContent>
</ContextMenu>
```

## Examples

### File browser

Each tile has its own menu. Duplicate, star and trash change the grid; the tile stays lit while its menu is open.

```tsx
"use client";

import * as React from "react";
import {
  CopyPlus,
  FileCode2,
  FileImage,
  FileText,
  Link,
  Mail,
  PencilLine,
  Share2,
  SquareArrowOutUpRight,
  Star,
  Trash2,
  type LucideIcon,
} from "@/components/ui/icons";
import {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";

type FileEntry = { id: string; name: string; meta: string; icon: LucideIcon };

const library: FileEntry[] = [
  { id: "brief", name: "launch-brief.md", meta: "Edited 2h ago", icon: FileText },
  { id: "hero", name: "hero-v4.png", meta: "2.4 MB", icon: FileImage },
  { id: "worker", name: "edge-worker.ts", meta: "Edited yesterday", icon: FileCode2 },
  { id: "roadmap", name: "roadmap-q4.md", meta: "Edited Oct 2", icon: FileText },
  { id: "og", name: "og-image.png", meta: "312 KB", icon: FileImage },
  { id: "schema", name: "schema.sql", meta: "Edited Sep 28", icon: FileCode2 },
];

function copyName(name: string) {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? `${name.slice(0, dot)} copy${name.slice(dot)}` : `${name} copy`;
}

export default function ContextMenuDemo() {
  const [files, setFiles] = React.useState(library);
  const [starred, setStarred] = React.useState<string[]>(["roadmap"]);
  const [openId, setOpenId] = React.useState<string | null>(null);
  const [copies, setCopies] = React.useState(0);
  const trashed = library.filter((file) => !files.some((entry) => entry.id === file.id)).length;

  function duplicate(file: FileEntry) {
    const copy = { ...file, id: `${file.id}-copy-${copies + 1}`, name: copyName(file.name), meta: "Just now" };
    setCopies(copies + 1);
    setFiles((current) => {
      const index = current.findIndex((entry) => entry.id === file.id);
      return [...current.slice(0, index + 1), copy, ...current.slice(index + 1)];
    });
  }

  return (
    <div className="w-full max-w-md">
      <ul className="grid grid-cols-2 gap-2 sm:grid-cols-3">
        {files.map((file) => {
          const isStarred = starred.includes(file.id);
          const Icon = file.icon;
          return (
            <li key={file.id}>
              <ContextMenu onOpenChange={(open) => setOpenId(open ? file.id : null)}>
                <ContextMenuTrigger asChild>
                  <button
                    type="button"
                    className={cn(
                      "flex w-full select-none flex-col gap-3 rounded-xl border p-3 text-left outline-none",
                      "transition-[border-color,background-color,box-shadow] duration-150 ease-out",
                      "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
                      openId === file.id
                        ? "border-[rgb(var(--neon)/0.45)] bg-[rgb(var(--neon)/0.05)] shadow-[0_0_20px_-8px_rgb(var(--neon)/0.6)]"
                        : "border-white/10 bg-white/[0.03] hover:border-white/20",
                    )}
                  >
                    <span className="flex items-center justify-between">
                      <Icon aria-hidden className="size-5 text-white/50" />
                      {isStarred && (
                        <Star aria-label="Starred" className="size-3.5 fill-current text-[rgb(var(--neon))]" />
                      )}
                    </span>
                    <span className="min-w-0">
                      <span className="block truncate text-sm text-white/85">{file.name}</span>
                      <span className="block truncate text-xs leading-5 text-white/40">{file.meta}</span>
                    </span>
                  </button>
                </ContextMenuTrigger>

                <ContextMenuContent className="w-56" aria-label={`Actions for ${file.name}`}>
                  <ContextMenuItem icon={<SquareArrowOutUpRight />} shortcut="⌘O">
                    Open
                  </ContextMenuItem>
                  <ContextMenuItem icon={<PencilLine />} shortcut="F2">
                    Rename
                  </ContextMenuItem>
                  <ContextMenuItem icon={<CopyPlus />} shortcut="⌘D" onSelect={() => duplicate(file)}>
                    Duplicate
                  </ContextMenuItem>
                  <ContextMenuSub>
                    <ContextMenuSubTrigger icon={<Share2 />}>Share</ContextMenuSubTrigger>
                    <ContextMenuSubContent className="w-48">
                      <ContextMenuItem icon={<Link />} shortcut="⌘L">
                        Copy link
                      </ContextMenuItem>
                      <ContextMenuItem icon={<Mail />}>Email…</ContextMenuItem>
                    </ContextMenuSubContent>
                  </ContextMenuSub>
                  <ContextMenuSeparator />
                  <ContextMenuCheckboxItem
                    checked={isStarred}
                    onCheckedChange={(checked) =>
                      setStarred((current) =>
                        checked ? [...current, file.id] : current.filter((id) => id !== file.id),
                      )
                    }
                  >
                    Starred
                  </ContextMenuCheckboxItem>
                  <ContextMenuSeparator />
                  <ContextMenuItem
                    variant="danger"
                    icon={<Trash2 />}
                    shortcut="⌘⌫"
                    onSelect={() => setFiles((current) => current.filter((entry) => entry.id !== file.id))}
                  >
                    Move to trash
                  </ContextMenuItem>
                </ContextMenuContent>
              </ContextMenu>
            </li>
          );
        })}
      </ul>

      <div className="mt-3 flex h-5 items-center justify-between text-xs text-white/40">
        <span>Right-click a file, or long-press on touch.</span>
        {trashed > 0 && (
          <button
            type="button"
            onClick={() => setFiles(library)}
            className="rounded-md text-[rgb(var(--neon))] outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
          >
            Restore {trashed} from trash
          </button>
        )}
      </div>
    </div>
  );
}
```

### Canvas

One menu for a whole area, with a radio submenu and checkbox items that drive the canvas.

```tsx
"use client";

import * as React from "react";
import { ZoomIn } from "@/components/ui/icons";
import {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuRadioGroup,
  ContextMenuRadioItem,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";

const zoomLevels = [
  { value: "50", shortcut: "⇧0" },
  { value: "100", shortcut: "⇧1" },
  { value: "200", shortcut: "⇧2" },
];

export default function ContextMenuCanvas() {
  const [zoom, setZoom] = React.useState("100");
  const [grid, setGrid] = React.useState(true);
  const [rulers, setRulers] = React.useState(false);

  return (
    <ContextMenu>
      <ContextMenuTrigger
        tabIndex={0}
        aria-label="Design canvas"
        className="relative h-64 w-full max-w-lg select-none overflow-hidden rounded-xl border border-white/10 bg-[#07080d] outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]"
      >
        {grid && (
          <div
            aria-hidden
            className="absolute inset-0 bg-[radial-gradient(rgb(255_255_255/0.09)_1px,transparent_1px)] [background-size:16px_16px]"
          />
        )}
        {rulers && (
          <>
            <div aria-hidden className="absolute inset-x-0 top-0 h-4 border-b border-white/10 bg-white/[0.03]" />
            <div aria-hidden className="absolute inset-y-0 left-0 w-4 border-r border-white/10 bg-white/[0.03]" />
          </>
        )}
        <div
          className="absolute inset-0 grid place-items-center transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
          style={{ transform: `scale(${Number(zoom) / 100})` }}
        >
          <div className="w-44 rounded-lg border border-white/10 bg-[#0a0b11] p-3 shadow-[0_12px_32px_-12px_rgb(0_0_0/0.8)]">
            <div className="h-2 w-16 rounded-full bg-[rgb(var(--neon)/0.7)]" />
            <div className="mt-3 h-1.5 w-full rounded-full bg-white/10" />
            <div className="mt-1.5 h-1.5 w-3/4 rounded-full bg-white/10" />
            <div className="mt-3 h-5 w-14 rounded-md border border-[rgb(var(--neon)/0.5)]" />
          </div>
        </div>
        <span className="absolute bottom-3 right-3 rounded-md bg-black/40 px-1.5 py-0.5 text-[11px] tabular-nums text-white/50">
          {zoom}%
        </span>
        <span className="absolute bottom-3 left-3 text-[11px] text-white/35">Right-click anywhere on the canvas</span>
      </ContextMenuTrigger>

      <ContextMenuContent className="w-56" aria-label="Canvas">
        <ContextMenuItem inset shortcut="⌘V" disabled>
          Paste
        </ContextMenuItem>
        <ContextMenuItem inset shortcut="⌘A">
          Select all
        </ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuSub>
          <ContextMenuSubTrigger icon={<ZoomIn />}>Zoom</ContextMenuSubTrigger>
          <ContextMenuSubContent className="w-40">
            <ContextMenuRadioGroup value={zoom} onValueChange={setZoom}>
              <ContextMenuLabel inset>Zoom to</ContextMenuLabel>
              {zoomLevels.map((level) => (
                <ContextMenuRadioItem key={level.value} value={level.value} shortcut={level.shortcut}>
                  {level.value}%
                </ContextMenuRadioItem>
              ))}
            </ContextMenuRadioGroup>
          </ContextMenuSubContent>
        </ContextMenuSub>
        <ContextMenuSeparator />
        <ContextMenuCheckboxItem checked={grid} onCheckedChange={setGrid} shortcut="⌘'">
          Show grid
        </ContextMenuCheckboxItem>
        <ContextMenuCheckboxItem checked={rulers} onCheckedChange={setRulers} shortcut="⇧R">
          Show rulers
        </ContextMenuCheckboxItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}
```

### Messages

Long-press a message on touch, or focus one and press Shift+F10.

```tsx
"use client";

import * as React from "react";
import { Copy, MailOpen, Pin, Reply, Trash2 } from "@/components/ui/icons";
import { Avatar } from "@/components/ui/avatar";
import {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";

const thread = [
  { id: "m1", author: "Mara Okafor", time: "09:12", text: "Preview for the pricing page is up. The plan cards finally line up on mobile." },
  { id: "m2", author: "Jonas Weber", time: "09:15", text: "Looks great. Can we ship it behind the flag first and watch conversion for a day?" },
  { id: "m3", author: "Priya Raman", time: "09:21", text: "Flag is on for 10% of traffic. I'll post numbers after lunch." },
];

export default function ContextMenuMessages() {
  const [messages, setMessages] = React.useState(thread);
  const [pinned, setPinned] = React.useState<string | null>("m3");

  return (
    <div className="w-full max-w-md rounded-xl border border-white/10 bg-white/[0.02] p-2">
      {messages.map((message) => (
        <ContextMenu key={message.id}>
          <ContextMenuTrigger asChild>
            <article
              tabIndex={0}
              aria-label={`${message.author} at ${message.time}`}
              className="flex select-none gap-3 rounded-lg p-2.5 outline-none transition-colors duration-150 hover:bg-white/[0.03] focus-visible:bg-white/[0.04] focus-visible:ring-1 focus-visible:ring-[rgb(var(--neon)/0.5)] data-[state=open]:bg-white/[0.04]"
            >
              <Avatar alt={message.author} size="sm" />
              <div className="min-w-0 flex-1">
                <p className="flex items-center gap-2 text-xs">
                  <span className="font-medium text-white/85">{message.author}</span>
                  <time className="text-white/35">{message.time}</time>
                  {pinned === message.id && (
                    <Pin aria-label="Pinned" className="size-3 text-[rgb(var(--neon))]" />
                  )}
                </p>
                <p className="mt-0.5 text-sm leading-5 text-white/70">{message.text}</p>
              </div>
            </article>
          </ContextMenuTrigger>
          <ContextMenuContent className="w-52" aria-label="Message actions">
            <ContextMenuItem icon={<Reply />} shortcut="R">
              Reply
            </ContextMenuItem>
            <ContextMenuItem
              icon={<Copy />}
              shortcut="⌘C"
              onSelect={() => navigator.clipboard?.writeText(message.text).catch(() => {})}
            >
              Copy text
            </ContextMenuItem>
            <ContextMenuItem icon={<MailOpen />} shortcut="U">
              Mark unread
            </ContextMenuItem>
            <ContextMenuCheckboxItem
              checked={pinned === message.id}
              onCheckedChange={(checked) => setPinned(checked ? message.id : null)}
            >
              Pin to thread
            </ContextMenuCheckboxItem>
            <ContextMenuSeparator />
            <ContextMenuItem
              variant="danger"
              icon={<Trash2 />}
              onSelect={() => setMessages((current) => current.filter((entry) => entry.id !== message.id))}
            >
              Delete message
            </ContextMenuItem>
          </ContextMenuContent>
        </ContextMenu>
      ))}
      {messages.length === 0 && (
        <button
          type="button"
          onClick={() => setMessages(thread)}
          className="w-full rounded-lg p-4 text-sm text-white/50 outline-none hover:text-white/70 focus-visible:ring-1 focus-visible:ring-[rgb(var(--neon)/0.5)]"
        >
          Thread cleared. Bring it back
        </button>
      )}
    </div>
  );
}
```

## API reference

### ContextMenu

Holds the state and the pointer position. Renders no element of its own. Built on DropdownMenu with anchorPoint.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onOpenChange` | `(open: boolean) => void` | — | Called when the menu opens or closes. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Without it the menu takes the trigger area's color, then --neon-accent, then cyan. |

### ContextMenuTrigger

The area that answers right-click. Renders a div and accepts its native attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Uses the single child as the area instead of rendering a div. |
| `disabled` | `boolean` | `false` | Lets the browser's own context menu through. |

### ContextMenuContent

The same panel as DropdownMenuContent, opened at the pointer.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"right"` | Which side of the pointer to open on. Flips near the viewport edge. |
| `align` | `"start" \| "center" \| "end"` | `"start"` | Alignment against the pointer. |
| `sideOffset` | `number` | `2` | Gap from the pointer in px, so the item under the cursor isn't hovered on open. |
| `loop` | `boolean` | `true` | Arrow keys wrap around. |

### Items

ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuLabel, ContextMenuGroup, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger and ContextMenuSubContent are the Dropdown Menu parts under new names, with the same props.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon` | `ReactNode` | — | Leading icon on items and sub triggers. |
| `shortcut` | `ReactNode` | — | Right-aligned hint. |
| `variant` | `"default" \| "danger"` | `"default"` | danger turns the item red. |
| `onSelect` | `(event: Event) => void` | — | Called on select. The menu closes unless you call event.preventDefault(). |

## Accessibility

- Opens at the pointer on right-click, after a 500ms long-press on touch (moving more than 10px cancels it), and from the keyboard with Shift+F10 or the ContextMenu key at the area's top-left corner.
- The browser's own context menu is suppressed inside the trigger and the menu. Set disabled to give it back.
- For Shift+F10 to reach the area, it or something inside it must be focusable: give the area tabIndex={0} or use focusable children.
- The keyboard model is the Dropdown Menu's: arrows, Home and End, typeahead, Enter or Space to select, ArrowRight and ArrowLeft for submenus, Escape and Tab to close.
- Focus returns to whatever had it before the menu opened. The menu has no trigger to name it, so pass aria-label to ContextMenuContent.

## Source

`components/ui/context-menu.tsx`

```tsx
"use client";

import * as React from "react";

import {
  DropdownMenu,
  DropdownMenuAnchor,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  type DropdownMenuCheckboxItemProps,
  type DropdownMenuContentProps,
  type DropdownMenuItemProps,
  type DropdownMenuLabelProps,
  type DropdownMenuRadioGroupProps,
  type DropdownMenuRadioItemProps,
  type DropdownMenuSubContentProps,
  type DropdownMenuSubTriggerProps,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

const LONG_PRESS = 500;
const MOVE_TOLERANCE = 10;
// The ContextMenu key, Shift+F10 and an Android long-press can each be followed by a native
// contextmenu event; ignore it for a moment after we've opened the menu ourselves.
const ECHO_WINDOW = 500;

interface ContextMenuContextValue {
  open: boolean;
  color?: NeonColor;
  openAt: (x: number, y: number) => void;
  /** Bumps on every open so a second right-click moves the menu instead of reviving the old one. */
  generation: number;
}

const ContextMenuContext = React.createContext<ContextMenuContextValue | null>(null);

function useContextMenu(component: string) {
  const context = React.useContext(ContextMenuContext);
  if (!context) throw new Error(`<${component}> must be rendered inside <ContextMenu>.`);
  return context;
}

export interface ContextMenuProps {
  onOpenChange?: (open: boolean) => void;
  /** Palette name or hex. When omitted the menu takes the trigger area's color, then `--neon-accent`. */
  color?: NeonColor;
  children?: React.ReactNode;
}

function ContextMenu({ onOpenChange, color, children }: ContextMenuProps) {
  const [open, setOpen] = React.useState(false);
  const [point, setPoint] = React.useState({ x: 0, y: 0 });
  const [generation, setGeneration] = React.useState(0);

  const handleOpenChange = React.useCallback(
    (next: boolean) => {
      setOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );

  const openAt = React.useCallback(
    (x: number, y: number) => {
      setPoint({ x, y });
      setGeneration((count) => count + 1);
      setOpen(true);
      if (!open) onOpenChange?.(true);
    },
    [open, onOpenChange],
  );

  const context = React.useMemo(() => ({ open, color, openAt, generation }), [open, color, openAt, generation]);

  return (
    <ContextMenuContext.Provider value={context}>
      <DropdownMenu open={open} onOpenChange={handleOpenChange} anchorPoint={point} color={color}>
        {children}
      </DropdownMenu>
    </ContextMenuContext.Provider>
  );
}
ContextMenu.displayName = "ContextMenu";

export interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Merge the right-click behaviour onto the single child element instead of rendering a div. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
  /** Lets the browser's own context menu through. */
  disabled?: boolean;
}

const ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(
  (raw, ref) => {
    const {
      asChild = false,
      disabled = false,
      className,
      style,
      onContextMenu,
      onPointerDown,
      onPointerMove,
      onPointerUp,
      onPointerCancel,
      onKeyDown,
      ...props
    } = fromRenderProp(raw);
    const { open, color, openAt } = useContextMenu("ContextMenuTrigger");
    const press = React.useRef<{ x: number; y: number; timer: number } | null>(null);
    const openedAt = React.useRef(-Infinity);

    const cancelPress = React.useCallback(() => {
      if (!press.current) return;
      window.clearTimeout(press.current.timer);
      press.current = null;
    }, []);

    React.useEffect(() => cancelPress, [cancelPress]);

    return (
      <DropdownMenuAnchor
        ref={ref}
        asChild={asChild}
        data-state={open ? "open" : "closed"}
        data-disabled={disabled ? "" : undefined}
        // Stop iOS from showing its own callout over the long-press.
        style={neonVars(color, disabled ? style : { WebkitTouchCallout: "none", ...style })}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", className)}
        {...props}
        onContextMenu={(event) => {
          onContextMenu?.(event);
          cancelPress();
          if (event.defaultPrevented || disabled) return;
          event.preventDefault();
          if (performance.now() - openedAt.current < ECHO_WINDOW) return;
          openAt(event.clientX, event.clientY);
        }}
        onPointerDown={(event) => {
          onPointerDown?.(event);
          cancelPress();
          if (event.defaultPrevented || disabled || event.pointerType === "mouse") return;
          const { clientX: x, clientY: y } = event;
          press.current = {
            x,
            y,
            timer: window.setTimeout(() => {
              press.current = null;
              openedAt.current = performance.now();
              openAt(x, y);
            }, LONG_PRESS),
          };
        }}
        onPointerMove={(event) => {
          onPointerMove?.(event);
          const start = press.current;
          if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > MOVE_TOLERANCE) cancelPress();
        }}
        onPointerUp={(event) => {
          onPointerUp?.(event);
          cancelPress();
        }}
        onPointerCancel={(event) => {
          onPointerCancel?.(event);
          cancelPress();
        }}
        onKeyDown={(event) => {
          onKeyDown?.(event);
          if (event.defaultPrevented || disabled) return;
          if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
            event.preventDefault();
            openedAt.current = performance.now();
            const rect = event.currentTarget.getBoundingClientRect();
            openAt(rect.left, rect.top);
          }
        }}
      />
    );
  },
);
ContextMenuTrigger.displayName = "ContextMenuTrigger";

export type ContextMenuContentProps = DropdownMenuContentProps;

const ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(
  ({ side = "right", align = "start", sideOffset = 2, onContextMenu, ...props }, ref) => {
    const { generation } = useContextMenu("ContextMenuContent");
    return (
      <DropdownMenuContent
        key={generation}
        ref={ref}
        side={side}
        align={align}
        sideOffset={sideOffset}
        // A right-click inside the menu shouldn't stack the browser's menu on top of ours.
        onContextMenu={(event) => {
          onContextMenu?.(event);
          event.preventDefault();
        }}
        {...props}
      />
    );
  },
);
ContextMenuContent.displayName = "ContextMenuContent";

export type ContextMenuItemProps = DropdownMenuItemProps;
export type ContextMenuCheckboxItemProps = DropdownMenuCheckboxItemProps;
export type ContextMenuRadioGroupProps = DropdownMenuRadioGroupProps;
export type ContextMenuRadioItemProps = DropdownMenuRadioItemProps;
export type ContextMenuLabelProps = DropdownMenuLabelProps;
export type ContextMenuSubTriggerProps = DropdownMenuSubTriggerProps;
export type ContextMenuSubContentProps = DropdownMenuSubContentProps;

export {
  ContextMenu,
  ContextMenuTrigger,
  ContextMenuContent,
  DropdownMenuItem as ContextMenuItem,
  DropdownMenuCheckboxItem as ContextMenuCheckboxItem,
  DropdownMenuRadioGroup as ContextMenuRadioGroup,
  DropdownMenuRadioItem as ContextMenuRadioItem,
  DropdownMenuLabel as ContextMenuLabel,
  DropdownMenuGroup as ContextMenuGroup,
  DropdownMenuSeparator as ContextMenuSeparator,
  DropdownMenuSub as ContextMenuSub,
  DropdownMenuSubTrigger as ContextMenuSubTrigger,
  DropdownMenuSubContent as ContextMenuSubContent,
};
```
