# Context Menu

> Right-click menu at the pointer with icons, shortcuts, destructive items, separators and arrow-key navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/context-menu
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/context-menu.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/context-menu-demo.tsx"
"use client"

import * as React from "react"
import { Copy, Download, Pencil, Share2, Trash2 } from "lucide-react"

import { ContextMenu, type ContextMenuEntry } from "@/components/context-menu"

export default function ContextMenuDemo() {
  const [last, setLast] = React.useState("Nothing yet")

  const items: ContextMenuEntry[] = [
    { label: "Rename", icon: Pencil, shortcut: "F2", onSelect: () => setLast("Rename") },
    { label: "Duplicate", icon: Copy, shortcut: "⌘D", onSelect: () => setLast("Duplicate") },
    { label: "Share", icon: Share2, onSelect: () => setLast("Share") },
    { label: "Export", icon: Download, disabled: true },
    { type: "separator" },
    { label: "Delete", icon: Trash2, shortcut: "⌫", destructive: true, onSelect: () => setLast("Delete") },
  ]

  return (
    <div className="grid w-full max-w-md gap-3">
      <ContextMenu
        items={items}
        menuLabel="File actions"
        className="flex h-52 items-center justify-center rounded-2xl border-2 border-dashed bg-card text-sm text-muted-foreground"
      >
        Right-click anywhere in this area
      </ContextMenu>
      <p className="text-center text-xs text-muted-foreground">
        Last action: <span className="font-medium text-foreground">{last}</span>
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/context-menu.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type ContextMenuEntry =
  | {
      type?: "item"
      label: string
      icon?: React.ComponentType<{ className?: string }>
      shortcut?: string
      destructive?: boolean
      disabled?: boolean
      onSelect?: () => void
    }
  | { type: "separator" }

export interface ContextMenuProps extends React.ComponentProps<"div"> {
  items: ContextMenuEntry[]
  menuLabel?: string
}

const MENU_WIDTH = 208

// Right-click anywhere inside to open the menu at the pointer. It must sit in a positioned container.
export function ContextMenu({ items, menuLabel = "Context menu", className, children, onContextMenu, ...props }: ContextMenuProps) {
  const [pos, setPos] = React.useState<{ x: number; y: number } | null>(null)
  const root = React.useRef<HTMLDivElement>(null)
  const menu = React.useRef<HTMLDivElement>(null)

  const focusFirst = React.useCallback((node: HTMLDivElement | null) => {
    menu.current = node
    node?.querySelector<HTMLElement>("[role=menuitem]:not(:disabled)")?.focus()
  }, [])

  React.useEffect(() => {
    if (!pos) return
    const close = () => setPos(null)
    const onPointerDown = (event: PointerEvent) => {
      if (!menu.current?.contains(event.target as Node)) close()
    }
    document.addEventListener("pointerdown", onPointerDown)
    window.addEventListener("blur", close)
    window.addEventListener("resize", close)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      window.removeEventListener("blur", close)
      window.removeEventListener("resize", close)
    }
  }, [pos])

  const enabled = () => [...(menu.current?.querySelectorAll<HTMLElement>("[role=menuitem]:not(:disabled)") ?? [])]
  const move = (step: number) => {
    const all = enabled()
    const index = all.indexOf(document.activeElement as HTMLElement)
    all[(index + step + all.length) % all.length]?.focus()
  }

  return (
    <div
      ref={root}
      className={cn("relative", className)}
      onContextMenu={(event) => {
        onContextMenu?.(event)
        event.preventDefault()
        const rect = event.currentTarget.getBoundingClientRect()
        const height = items.reduce((sum, item) => sum + (item.type === "separator" ? 9 : 32), 8)
        setPos({
          x: Math.max(4, Math.min(event.clientX - rect.left, rect.width - MENU_WIDTH - 4)),
          y: Math.max(4, Math.min(event.clientY - rect.top, rect.height - height - 4)),
        })
      }}
      {...props}
    >
      {children}
      {pos && (
        <div
          ref={focusFirst}
          role="menu"
          aria-label={menuLabel}
          style={{ left: pos.x, top: pos.y, width: MENU_WIDTH }}
          onKeyDown={(event) => {
            if (event.key === "ArrowDown") move(1)
            else if (event.key === "ArrowUp") move(-1)
            else if (event.key === "Escape" || event.key === "Tab") setPos(null)
            else return
            event.preventDefault()
          }}
          className="absolute z-30 animate-in rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 fade-in zoom-in-95"
        >
          {items.map((item, index) => {
            if (item.type === "separator") return <div key={index} role="separator" className="-mx-1 my-1 h-px bg-border" />
            const Icon = item.icon
            return (
              <button
                key={item.label}
                type="button"
                role="menuitem"
                disabled={item.disabled}
                onClick={() => {
                  item.onSelect?.()
                  setPos(null)
                }}
                className={cn(
                  "flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm outline-none focus:bg-accent disabled:pointer-events-none disabled:opacity-40",
                  item.destructive && "text-destructive focus:bg-destructive/10"
                )}
              >
                {Icon && <Icon className="size-4 opacity-70" />}
                <span className="flex-1">{item.label}</span>
                {item.shortcut && <span className="text-xs text-muted-foreground">{item.shortcut}</span>}
              </button>
            )
          })}
        </div>
      )}
    </div>
  )
}
```
