{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-menu","type":"registry:component","title":"Context Menu","description":"A right-click menu that pops open from the cursor.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/context-menu/context-menu.tsx","type":"registry:component","target":"components/dimaac/context-menu.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type RefObject, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A right-click menu that opens where the cursor is. Wrap anything in it and give it a list of items.\n// It opens with a quick pop from the cursor and a stagger on the rows, one soft highlight glides\n// between the rows, and it is a real menu for keyboards and screen readers (arrow keys, Home and End,\n// type a letter to jump, Enter to choose, Escape to close and give focus back). On touch screens a\n// long press opens it. The document listeners exist only while the menu is open.\n\nexport type ContextMenuItem = {\n  /** A unique id for the row. */\n  id: string;\n  /** The text of the row. */\n  label: string;\n  /** An icon shown before the label, e.g. a lucide-react icon: <Heart size={16} />. */\n  icon?: ReactNode;\n  /** A shortcut hint shown on the right, e.g. \"⌘ D\". It is only a label: the shortcut itself is yours to handle. */\n  shortcut?: string;\n  /** Called when the row is chosen. */\n  onSelect?: (item: ContextMenuItem) => void;\n  /** Draw a divider line after this row. */\n  separator?: boolean;\n  /** Show the row greyed out and skip it. */\n  disabled?: boolean;\n  /** Colour the row red, for things like Delete. */\n  danger?: boolean;\n};\n\nexport type ContextMenuProps = {\n  /** What the menu opens on. Right-click (or long-press on touch) anywhere inside it. */\n  children: ReactNode;\n  /** The rows of the menu. */\n  items: ContextMenuItem[];\n  /** Called with the row that was chosen (after the row's own onSelect). */\n  onItemSelect?: (item: ContextMenuItem) => void;\n  /** Called when the menu opens (true) and starts closing (false). */\n  onOpenChange?: (open: boolean) => void;\n  /** Width of the menu, in pixels. */\n  width?: number;\n  /** How long the menu takes to pop open, in seconds. */\n  openDuration?: number;\n  /** Delay between one row appearing and the next, in seconds. */\n  stagger?: number;\n  /** How small the menu starts, from 0.5 to 1. It grows from the cursor. */\n  startScale?: number;\n  /** The GSAP ease of the pop, e.g. \"power3.out\" or \"back.out(1.7)\". */\n  ease?: string;\n  /** The colour of the highlight and the focus. */\n  accent?: string;\n  /** A frosted-glass menu (blurred, see-through) instead of a solid one. */\n  glass?: boolean;\n  /** Turn the custom menu off, so the browser's own menu shows. */\n  disabled?: boolean;\n  /** How long a finger must stay down on touch screens to open the menu, in milliseconds. */\n  longPress?: number;\n  /** Extra classes for the box around the children. */\n  className?: string;\n  /** Extra classes for the menu itself, e.g. a different radius. */\n  menuClassName?: string;\n};\n\ntype Opening = { x: number; y: number; key: number; keyboard: boolean };\n\ntype MenuSettings = Required<Pick<ContextMenuProps, \"width\" | \"openDuration\" | \"stagger\" | \"startScale\" | \"ease\" | \"accent\" | \"glass\" | \"menuClassName\">>;\n\n// Menus can sit inside each other (a card inside a board). The innermost one opens, and marks the\n// event here so the ones around it leave it alone.\nconst claimed = new WeakSet<Event>();\n\nexport function ContextMenu({\n  children,\n  items,\n  onItemSelect,\n  onOpenChange,\n  width = 232,\n  openDuration = 0.26,\n  stagger = 0.022,\n  startScale = 0.9,\n  ease = \"power3.out\",\n  accent = \"#0f766e\",\n  glass = true,\n  disabled = false,\n  longPress = 480,\n  className = \"\",\n  menuClassName = \"\",\n}: ContextMenuProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const [opening, setOpening] = useState<Opening | null>(null);\n  const [closing, setClosing] = useState(false);\n  const counter = useRef(0);\n  const lastOpenAt = useRef(0);\n  const lastLongPressAt = useRef(0);\n  const pressTimer = useRef<number | null>(null);\n  const pressStart = useRef({ x: 0, y: 0 });\n  // The callback can change on every render: keep the newest one without re-running effects\n  const onOpenChangeRef = useRef(onOpenChange);\n  onOpenChangeRef.current = onOpenChange;\n  const reported = useRef(false);\n\n  const open = opening !== null && !closing;\n  useEffect(() => {\n    if (reported.current === open) return;\n    reported.current = open;\n    onOpenChangeRef.current?.(open);\n  }, [open]);\n\n  const openAt = (x: number, y: number, keyboard: boolean) => {\n    counter.current += 1;\n    lastOpenAt.current = performance.now();\n    setClosing(false);\n    // A new key builds a fresh menu, so opening it somewhere else plays the pop again\n    setOpening({ x, y, key: counter.current, keyboard });\n  };\n\n  const clearPress = () => {\n    if (pressTimer.current !== null) window.clearTimeout(pressTimer.current);\n    pressTimer.current = null;\n  };\n  useEffect(() => clearPress, []);\n\n  const onContextMenu = (event: ReactMouseEvent) => {\n    if (disabled || items.length === 0 || claimed.has(event.nativeEvent)) return;\n    claimed.add(event.nativeEvent);\n    event.preventDefault();\n    // A long press on Android fires this a moment after our own timer already opened the menu\n    if (performance.now() - lastOpenAt.current < 900) return;\n    openAt(event.clientX, event.clientY, false);\n  };\n\n  // Touch screens: a long press opens the menu (iOS never sends a contextmenu event)\n  const onPointerDown = (event: ReactPointerEvent) => {\n    if (disabled || items.length === 0 || event.pointerType !== \"touch\" || !event.isPrimary || claimed.has(event.nativeEvent)) return;\n    claimed.add(event.nativeEvent);\n    clearPress();\n    const { clientX, clientY } = event;\n    pressStart.current = { x: clientX, y: clientY };\n    pressTimer.current = window.setTimeout(() => {\n      pressTimer.current = null;\n      lastLongPressAt.current = performance.now();\n      openAt(clientX, clientY, false);\n    }, longPress);\n  };\n  const onPointerMove = (event: ReactPointerEvent) => {\n    // A finger that moves is scrolling or dragging, not pressing\n    const start = pressStart.current;\n    if (pressTimer.current !== null && Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10) clearPress();\n  };\n  // The tap that ends a long press shouldn't also click what is under the finger\n  const swallowClick = (event: ReactMouseEvent) => {\n    if (performance.now() - lastLongPressAt.current < 600) {\n      event.preventDefault();\n      event.stopPropagation();\n    }\n  };\n\n  // The keyboard way in: the Menu key, or Shift + F10, opens it beside the focused element\n  const onKeyDown = (event: ReactKeyboardEvent) => {\n    if (disabled || items.length === 0 || claimed.has(event.nativeEvent)) return;\n    if (event.key !== \"ContextMenu\" && !(event.shiftKey && event.key === \"F10\")) return;\n    claimed.add(event.nativeEvent);\n    event.preventDefault();\n    const box = boxRef.current;\n    const focused = document.activeElement;\n    const target = focused instanceof HTMLElement && box?.contains(focused) ? focused : box;\n    if (!target) return;\n    const rect = target.getBoundingClientRect();\n    openAt(rect.left + Math.min(rect.width / 2, 48), rect.top + Math.min(rect.height / 2, 32), true);\n  };\n\n  return (\n    <div\n      ref={boxRef}\n      className={className}\n      onContextMenu={onContextMenu}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={clearPress}\n      onPointerCancel={clearPress}\n      onClickCapture={swallowClick}\n      onKeyDown={onKeyDown}\n      style={{ WebkitTouchCallout: \"none\" }}\n    >\n      {children}\n      {opening && !disabled\n        ? createPortal(\n            <Menu\n              key={opening.key}\n              opening={opening}\n              items={items}\n              settings={{ width, openDuration, stagger, startScale, ease, accent, glass, menuClassName }}\n              onItemSelect={onItemSelect}\n              onClosing={() => setClosing(true)}\n              onDone={() => setOpening((current) => (current && current.key === opening.key ? null : current))}\n              fallbackFocus={boxRef}\n            />,\n            document.body,\n          )\n        : null}\n    </div>\n  );\n}\n\ntype MenuProps = {\n  opening: Opening;\n  items: ContextMenuItem[];\n  settings: MenuSettings;\n  onItemSelect?: (item: ContextMenuItem) => void;\n  onClosing: () => void;\n  onDone: () => void;\n  fallbackFocus: RefObject<HTMLElement | null>;\n};\n\n// The menu itself exists only while it is open, so everything it listens to goes away with it.\nfunction Menu({ opening, items, settings, onItemSelect, onClosing, onDone, fallbackFocus }: MenuProps) {\n  const menuRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const pillRef = useRef<HTMLDivElement>(null);\n  const [leaving, setLeaving] = useState(false);\n  const leavingRef = useRef(false);\n  const exitRef = useRef<() => void>(() => undefined);\n  const previousFocus = useRef<Element | null>(null);\n  const typed = useRef({ text: \"\", timer: 0 });\n  // Read when the menu opens or closes, so a changing control never rebuilds an open menu\n  const settingsRef = useRef(settings);\n  settingsRef.current = settings;\n  const onDoneRef = useRef(onDone);\n  onDoneRef.current = onDone;\n\n  // Where the menu goes, how it pops open, and the highlight that glides between rows\n  useGSAP(\n    (_context, contextSafe) => {\n      const menu = menuRef.current;\n      const list = listRef.current;\n      const pill = pillRef.current;\n      if (!menu || !list || !pill || !contextSafe) return;\n      // Remember where focus was, once (React StrictMode runs this twice, and by then the menu has focus)\n      if (previousFocus.current === null) previousFocus.current = document.activeElement;\n\n      // Put the menu at the cursor, flipped or pushed in when it would leave the screen\n      const margin = 8;\n      const w = menu.offsetWidth;\n      const h = menu.offsetHeight;\n      const maxLeft = Math.max(margin, window.innerWidth - w - margin);\n      const maxTop = Math.max(margin, window.innerHeight - h - margin);\n      const left = Math.min(Math.max(opening.x + w > window.innerWidth - margin ? opening.x - w : opening.x, margin), maxLeft);\n      const top = Math.min(Math.max(opening.y + h > window.innerHeight - margin ? opening.y - h : opening.y, margin), maxTop);\n      menu.style.left = `${left}px`;\n      menu.style.top = `${top}px`;\n      // The pop grows from the cursor, wherever the menu ended up\n      const originX = Math.min(Math.max(opening.x - left, 0), w);\n      const originY = Math.min(Math.max(opening.y - top, 0), h);\n      gsap.set(menu, { transformOrigin: `${originX}px ${originY}px` });\n\n      const rows = Array.from(list.querySelectorAll<HTMLElement>('[role=\"menuitem\"]'));\n      const dividers = Array.from(list.querySelectorAll<HTMLElement>('[role=\"separator\"]'));\n      const mm = gsap.matchMedia();\n      let glideY: ((value: number) => gsap.core.Tween) | null = null;\n      let glideH: ((value: number) => gsap.core.Tween) | null = null;\n      let animated = true;\n\n      mm.add(\n        { reduce: \"(prefers-reduced-motion: reduce)\", motion: \"(prefers-reduced-motion: no-preference)\" },\n        (context) => {\n          animated = !context.conditions?.reduce;\n          const { openDuration, stagger, startScale, ease } = settingsRef.current;\n          if (!animated) {\n            // No movement: the menu is simply there\n            gsap.set([menu, ...rows, ...dividers], { opacity: 1, clearProps: \"transform\" });\n            return;\n          }\n          gsap.set(pill, { opacity: 0 });\n          glideY = gsap.quickTo(pill, \"y\", { duration: 0.22, ease: \"power3.out\" });\n          glideH = gsap.quickTo(pill, \"height\", { duration: 0.22, ease: \"power3.out\" });\n          gsap\n            .timeline({ defaults: { overwrite: \"auto\" } })\n            .fromTo(menu, { opacity: 0, scale: startScale, y: -6 }, { opacity: 1, scale: 1, y: 0, duration: openDuration, ease, clearProps: \"transform\" }, 0)\n            .fromTo([...rows, ...dividers], { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: openDuration * 0.9, ease: \"power2.out\", stagger, clearProps: \"transform\" }, 0.04);\n        },\n      );\n\n      // The highlight follows whichever row has focus (the mouse moves focus too)\n      let lit = false;\n      const moveHighlight = contextSafe((row: HTMLElement) => {\n        if (animated && glideY && glideH) {\n          if (!lit) gsap.set(pill, { y: row.offsetTop, height: row.offsetHeight });\n          else {\n            glideY(row.offsetTop);\n            glideH(row.offsetHeight);\n          }\n          gsap.to(pill, { opacity: 1, duration: 0.15, overwrite: \"auto\" });\n        } else {\n          gsap.set(pill, { y: row.offsetTop, height: row.offsetHeight, opacity: 1 });\n        }\n        lit = true;\n      });\n      const onFocusIn = (event: FocusEvent) => {\n        const row = (event.target as HTMLElement).closest<HTMLElement>('[role=\"menuitem\"]');\n        if (row) moveHighlight(row);\n      };\n      const onFocusOut = contextSafe((event: FocusEvent) => {\n        if ((event.relatedTarget as HTMLElement | null)?.closest?.('[role=\"menuitem\"]')) return;\n        lit = false;\n        gsap.to(pill, { opacity: 0, duration: 0.15, overwrite: \"auto\" });\n      });\n      list.addEventListener(\"focusin\", onFocusIn);\n      list.addEventListener(\"focusout\", onFocusOut);\n\n      // Closing plays a short fade, then the parent removes the menu\n      exitRef.current = contextSafe(() => {\n        list.removeEventListener(\"focusin\", onFocusIn);\n        list.removeEventListener(\"focusout\", onFocusOut);\n        if (!animated) {\n          onDoneRef.current();\n          return;\n        }\n        gsap.to(menu, { opacity: 0, scale: 0.96, duration: 0.14, ease: \"power2.in\", overwrite: true, onComplete: () => onDoneRef.current() });\n      });\n\n      // Keyboard-opened menus start on the first row, mouse-opened ones on the menu itself\n      const first = rows.find((row) => row.getAttribute(\"aria-disabled\") !== \"true\");\n      (opening.keyboard && first ? first : menu).focus({ preventScroll: true });\n\n      return () => {\n        list.removeEventListener(\"focusin\", onFocusIn);\n        list.removeEventListener(\"focusout\", onFocusOut);\n        mm.revert();\n      };\n    },\n    { scope: menuRef, dependencies: [] },\n  );\n\n  // Start closing. Keyboard and row choices give focus back to where it was.\n  const close = (restoreFocus: boolean) => {\n    if (leavingRef.current) return;\n    leavingRef.current = true;\n    setLeaving(true);\n    onClosing();\n    if (restoreFocus) {\n      const back = previousFocus.current;\n      const hadFocus = back instanceof HTMLElement && back !== document.body && back.isConnected;\n      // Opened with the keyboard from something unfocusable: land on the box around it\n      const target = hadFocus ? back : opening.keyboard ? fallbackFocus.current : null;\n      if (target) {\n        if (!target.hasAttribute(\"tabindex\") && !(target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement || target instanceof HTMLInputElement)) target.tabIndex = -1;\n        target.focus({ preventScroll: true });\n      }\n    }\n    exitRef.current();\n  };\n\n  // Listeners that exist only while the menu is open: a press elsewhere, scrolling, a resized window\n  useEffect(() => {\n    if (leaving) return;\n    const menu = menuRef.current;\n    const onPressOutside = (event: PointerEvent) => {\n      if (menu && event.target instanceof Node && menu.contains(event.target)) return;\n      close(false);\n    };\n    const onAnyChange = () => close(false);\n    document.addEventListener(\"pointerdown\", onPressOutside, true);\n    window.addEventListener(\"scroll\", onAnyChange, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onAnyChange);\n    window.addEventListener(\"blur\", onAnyChange);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPressOutside, true);\n      window.removeEventListener(\"scroll\", onAnyChange, { capture: true });\n      window.removeEventListener(\"resize\", onAnyChange);\n      window.removeEventListener(\"blur\", onAnyChange);\n    };\n    // close only reads refs and stable callbacks\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [leaving]);\n\n  useEffect(() => () => window.clearTimeout(typed.current.timer), []);\n\n  const choose = (item: ContextMenuItem) => {\n    if (item.disabled || leavingRef.current) return;\n    close(true);\n    item.onSelect?.(item);\n    onItemSelect?.(item);\n  };\n\n  const rowElements = () => Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role=\"menuitem\"]:not([aria-disabled=\"true\"])') ?? []);\n\n  const onMenuKeyDown = (event: ReactKeyboardEvent) => {\n    const rows = rowElements();\n    const index = rows.indexOf(document.activeElement as HTMLElement);\n    const go = (next: number) => {\n      event.preventDefault();\n      rows[(next + rows.length) % rows.length]?.focus({ preventScroll: true });\n    };\n    if (event.key === \"ArrowDown\") go(index + 1);\n    else if (event.key === \"ArrowUp\") go(index < 0 ? rows.length - 1 : index - 1);\n    else if (event.key === \"Home\") go(0);\n    else if (event.key === \"End\") go(rows.length - 1);\n    else if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      close(true);\n    } else if (event.key === \"Tab\") {\n      event.preventDefault();\n      close(true);\n    } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey && event.key !== \" \") {\n      // Type a letter (or a few quickly) to jump to the row that starts with it\n      window.clearTimeout(typed.current.timer);\n      typed.current.text += event.key.toLowerCase();\n      typed.current.timer = window.setTimeout(() => (typed.current.text = \"\"), 600);\n      const ordered = [...rows.slice(index + 1), ...rows.slice(0, index + 1)];\n      const match = ordered.find((row) => (row.textContent ?? \"\").trim().toLowerCase().startsWith(typed.current.text));\n      if (match) {\n        event.preventDefault();\n        match.focus({ preventScroll: true });\n      }\n    }\n  };\n\n  const { width, accent, glass, menuClassName } = settings;\n  return (\n    <div\n      ref={menuRef}\n      role=\"menu\"\n      aria-orientation=\"vertical\"\n      tabIndex={-1}\n      onKeyDown={onMenuKeyDown}\n      onContextMenu={(event) => event.preventDefault()}\n      className={`fixed z-[2147483000] max-w-[calc(100vw-16px)] rounded-2xl border p-1.5 text-[13.5px] leading-none outline-none select-none ${\n        glass\n          ? \"border-black/10 bg-white/82 text-stone-900 shadow-[0_24px_60px_-18px_rgba(30,20,10,0.35),0_2px_6px_rgba(30,20,10,0.08)] backdrop-blur-2xl backdrop-saturate-150 dark:border-white/12 dark:bg-stone-900/80 dark:text-stone-100 dark:shadow-[0_28px_70px_-16px_rgba(0,0,0,0.75),0_2px_8px_rgba(0,0,0,0.4)]\"\n          : \"border-stone-200 bg-white text-stone-900 shadow-[0_24px_60px_-18px_rgba(30,20,10,0.35),0_2px_6px_rgba(30,20,10,0.08)] dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100 dark:shadow-[0_28px_70px_-16px_rgba(0,0,0,0.75)]\"\n      } ${menuClassName}`}\n      style={{ left: 0, top: 0, width, willChange: \"transform, opacity\", [\"--menu-accent\" as string]: accent }}\n    >\n      <div ref={listRef} className=\"relative flex flex-col\">\n        {/* The one highlight that glides between rows */}\n        <div\n          ref={pillRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[10px] opacity-0\"\n          style={{ background: \"color-mix(in oklab, var(--menu-accent) 16%, transparent)\", boxShadow: \"inset 0 0 0 1px color-mix(in oklab, var(--menu-accent) 28%, transparent)\" }}\n        />\n        {items.map((item) => (\n          <MenuRow key={item.id} item={item} onChoose={choose} />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction MenuRow({ item, onChoose }: { item: ContextMenuItem; onChoose: (item: ContextMenuItem) => void }) {\n  return (\n    <>\n      <button\n        type=\"button\"\n        role=\"menuitem\"\n        tabIndex={-1}\n        aria-disabled={item.disabled ? \"true\" : undefined}\n        onClick={() => onChoose(item)}\n        onPointerEnter={(event) => {\n          if (event.pointerType !== \"touch\" && !item.disabled) event.currentTarget.focus({ preventScroll: true });\n        }}\n        onPointerLeave={(event) => {\n          // Moving off the rows lets the highlight fade: focus goes back to the menu\n          if (event.pointerType !== \"touch\" && document.activeElement === event.currentTarget) (event.currentTarget.closest('[role=\"menu\"]') as HTMLElement | null)?.focus({ preventScroll: true });\n        }}\n        className={`relative flex w-full items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-left text-[13.5px] leading-5 outline-none pointer-coarse:py-2.5 ${\n          item.disabled ? \"cursor-not-allowed opacity-40\" : item.danger ? \"cursor-pointer text-red-600 dark:text-red-400\" : \"cursor-pointer\"\n        }`}\n      >\n        {item.icon ? (\n          <span aria-hidden=\"true\" className=\"flex h-4 w-4 shrink-0 items-center justify-center opacity-70\">\n            {item.icon}\n          </span>\n        ) : null}\n        <span className=\"flex-1 truncate\">{item.label}</span>\n        {item.shortcut ? <span className=\"ml-3 shrink-0 font-mono text-[11px] tracking-wide opacity-45\">{item.shortcut}</span> : null}\n      </button>\n      {item.separator ? <div role=\"separator\" className=\"mx-2 my-1 h-px bg-black/10 dark:bg-white/12\" /> : null}\n    </>\n  );\n}\n"}],"categories":["navigation","menu","right-click","gsap","keyboard","a11y"],"docs":"import { ContextMenu } from \"@/components/dimaac/context-menu\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/context-menu","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/context-menu","credits":[{"by":"West Kast","url":"https://www.lummi.ai/photo/futuristic-gold-profile-jnio5","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Bernardo Lickfold","url":"https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo","what":"Photo","source":"Lummi","licence":"Lummi License"}]}}