{"slug":"context-menu","name":"Animated Context Menu","description":"Composable context-menu primitives with a pointer-origin clip morph, a gliding active row, checkbox and radio choices, keyboard navigation, typeahead, and long-press support.","category":"motion","source_url":"https://beui.dev/r/context-menu/raw","detail_url":"https://beui.dev/r/context-menu","raw_url":"https://beui.dev/r/context-menu/raw","page_url":"https://beui.dev/components/motion/context-menu","markdown_url":"https://beui.dev/components/motion/context-menu.md","published_at":"2026-07-27","updated_at":"2026-07-27","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/context-menu","@/lib/ease","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/context-menu.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/context-menu\n\nimport { Check } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { holdSelection, TOUCH_GESTURE_CONTENT_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\ntype OpenModality = \"pointer\" | \"keyboard\" | \"touch\";\ntype MenuPoint = { x: number; y: number };\n\nconst VIEWPORT_PADDING = 8;\nconst LONG_PRESS_DELAY = 520;\nconst LONG_PRESS_TOLERANCE = 10;\nconst MORPH_DURATION = 0.3;\n\ntype TriggerElementProps = React.HTMLAttributes<HTMLElement> & {\n  ref?: Ref<HTMLElement>;\n};\n\ninterface ContextMenuContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openAt: (point: MenuPoint, modality: OpenModality) => void;\n  point: MenuPoint;\n  modality: OpenModality;\n  invocation: number;\n  menuId: string;\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n  activeId: string | null;\n  setActiveId: (id: string | null) => void;\n  reduce: boolean;\n}\n\nconst ContextMenuContext = createContext<ContextMenuContextValue | null>(null);\n\nfunction useContextMenuContext(component: string) {\n  const context = useContext(ContextMenuContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <ContextMenu>`);\n  }\n  return context;\n}\n\nfunction assignRef<T>(ref: Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") {\n    ref(value);\n  } else if (ref) {\n    ref.current = value;\n  }\n}\n\nfunction getEnabledItems(container: HTMLElement | null) {\n  if (!container) return [];\n  return Array.from(\n    container.querySelectorAll<HTMLElement>(\n      '[data-context-menu-item=\"true\"]:not([data-disabled=\"true\"])',\n    ),\n  );\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction collapsedClip(\n  origin: MenuPoint,\n  size: { width: number; height: number },\n) {\n  const half = 8;\n  const top = clamp(origin.y - half, 0, size.height);\n  const right = clamp(size.width - origin.x - half, 0, size.width);\n  const bottom = clamp(size.height - origin.y - half, 0, size.height);\n  const left = clamp(origin.x - half, 0, size.width);\n  return `inset(${top}px ${right}px ${bottom}px ${left}px round 10px)`;\n}\n\nexport interface ContextMenuProps {\n  children: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\nexport function ContextMenu({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: ContextMenuProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [point, setPoint] = useState<MenuPoint>({ x: 0, y: 0 });\n  const [modality, setModality] = useState<OpenModality>(\"pointer\");\n  const [invocation, setInvocation] = useState(0);\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n  const triggerRef = useRef<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const menuId = useId();\n  const reduce = useReducedMotion() ?? false;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n      if (!next) setActiveId(null);\n    },\n    [controlled, onOpenChange],\n  );\n\n  const openAt = useCallback(\n    (nextPoint: MenuPoint, nextModality: OpenModality) => {\n      setPoint(nextPoint);\n      setModality(nextModality);\n      setInvocation((current) => current + 1);\n      setActiveId(null);\n      setOpen(true);\n    },\n    [setOpen],\n  );\n\n  useEffect(() => {\n    if (!open) return;\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (!contentRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    const onWindowChange = () => setOpen(false);\n\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    window.addEventListener(\"resize\", onWindowChange);\n    window.addEventListener(\"scroll\", onWindowChange);\n    return () => {\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n      window.removeEventListener(\"resize\", onWindowChange);\n      window.removeEventListener(\"scroll\", onWindowChange);\n    };\n  }, [open, setOpen]);\n\n  const value = useMemo<ContextMenuContextValue>(\n    () => ({\n      open,\n      setOpen,\n      openAt,\n      point,\n      modality,\n      invocation,\n      menuId,\n      triggerRef,\n      contentRef,\n      activeId,\n      setActiveId,\n      reduce,\n    }),\n    [\n      open,\n      setOpen,\n      openAt,\n      point,\n      modality,\n      invocation,\n      menuId,\n      activeId,\n      reduce,\n    ],\n  );\n\n  return (\n    <ContextMenuContext.Provider value={value}>\n      <div className={cn(\"contents\", className)}>{children}</div>\n    </ContextMenuContext.Provider>\n  );\n}\n\nexport interface ContextMenuTriggerProps {\n  children: ReactElement<TriggerElementProps>;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function ContextMenuTrigger({\n  children,\n  disabled = false,\n  className,\n}: ContextMenuTriggerProps) {\n  const context = useContextMenuContext(\"ContextMenuTrigger\");\n  const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const touchOrigin = useRef<MenuPoint | null>(null);\n  const releaseSelection = useRef<(() => void) | null>(null);\n\n  const cancelLongPress = useCallback(() => {\n    if (longPressTimer.current) {\n      clearTimeout(longPressTimer.current);\n      longPressTimer.current = null;\n    }\n    touchOrigin.current = null;\n  }, []);\n\n  // Held for the whole press, not just the timer: a gesture that turned into a\n  // drag must not paint a selection under the finger either.\n  const endPress = useCallback(() => {\n    cancelLongPress();\n    releaseSelection.current?.();\n    releaseSelection.current = null;\n  }, [cancelLongPress]);\n\n  useEffect(() => endPress, [endPress]);\n\n  if (!isValidElement(children)) {\n    throw new Error(\"<ContextMenuTrigger> requires a single React element\");\n  }\n\n  const childProps = children.props;\n  const childRef = children.props.ref;\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {\n    childProps.onPointerDown?.(event);\n    // A pen presses the same way a finger does and gets no `contextmenu` out\n    // of the platform for it, so it holds to open too. A mouse has the right\n    // button and is left to `onContextMenu`.\n    const pressToOpen =\n      event.pointerType === \"touch\" || event.pointerType === \"pen\";\n    if (event.defaultPrevented || disabled || !pressToOpen) return;\n\n    // `pointer-coarse:select-none` misses this press on a laptop whose mouse\n    // is the primary pointer and whose touchscreen is not, and the platform's\n    // own long-press selection then claims the gesture and cancels ours. The\n    // press is the only thing that knows which input is on the glass, so it\n    // takes selection away itself — for its own duration, and no longer.\n    releaseSelection.current?.();\n    releaseSelection.current = holdSelection(event.currentTarget);\n\n    const origin = { x: event.clientX, y: event.clientY };\n    touchOrigin.current = origin;\n    longPressTimer.current = setTimeout(() => {\n      context.openAt(origin, \"touch\");\n      longPressTimer.current = null;\n      touchOrigin.current = null;\n    }, LONG_PRESS_DELAY);\n  };\n\n  const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {\n    childProps.onPointerMove?.(event);\n    const origin = touchOrigin.current;\n    if (\n      origin &&\n      Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >\n        LONG_PRESS_TOLERANCE\n    ) {\n      cancelLongPress();\n    }\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {\n    childProps.onKeyDown?.(event);\n    if (event.defaultPrevented || disabled) return;\n    if (event.key !== \"ContextMenu\" && !(event.shiftKey && event.key === \"F10\"))\n      return;\n\n    event.preventDefault();\n    const rect = event.currentTarget.getBoundingClientRect();\n    context.openAt(\n      { x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 },\n      \"keyboard\",\n    );\n  };\n\n  return cloneElement(children, {\n    ref: (node: HTMLElement | null) => {\n      context.triggerRef.current = node;\n      assignRef(childRef, node);\n    },\n    \"aria-controls\": context.open ? context.menuId : undefined,\n    \"aria-haspopup\": \"menu\",\n    \"aria-expanded\": context.open,\n    // The long press is ours: without this iOS runs its own on the same\n    // gesture and drops the selection callout and its handles on top of the\n    // menu we just opened. Only the press gesture is ours though — the child\n    // is the consumer's content, so a mouse can still select the text in it\n    // and right-click the selection. `touch-none` stays off too: the page\n    // still has to scroll from the trigger.\n    className: cn(TOUCH_GESTURE_CONTENT_CLASS, childProps.className, className),\n    onContextMenu: (event: ReactMouseEvent<HTMLElement>) => {\n      childProps.onContextMenu?.(event);\n      if (event.defaultPrevented || disabled) return;\n      event.preventDefault();\n      endPress();\n      context.openAt({ x: event.clientX, y: event.clientY }, \"pointer\");\n    },\n    onKeyDown,\n    onPointerDown,\n    onPointerMove,\n    onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {\n      childProps.onPointerUp?.(event);\n      endPress();\n    },\n    onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => {\n      childProps.onPointerCancel?.(event);\n      endPress();\n    },\n  });\n}\n\nexport interface ContextMenuContentProps {\n  children: ReactNode;\n  className?: string;\n  ariaLabel?: string;\n}\n\nexport function ContextMenuContent({\n  children,\n  className,\n  ariaLabel = \"Context menu\",\n}: ContextMenuContentProps) {\n  const context = useContextMenuContext(\"ContextMenuContent\");\n  const [mounted, setMounted] = useState(false);\n  const [position, setPosition] = useState<MenuPoint>(context.point);\n  const [origin, setOrigin] = useState<MenuPoint>({ x: 0, y: 0 });\n  const [size, setSize] = useState({ width: 0, height: 0 });\n  const [morphReady, setMorphReady] = useState(false);\n  const typeahead = useRef(\"\");\n  const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => setMounted(true), []);\n\n  useLayoutEffect(() => {\n    if (!context.open) {\n      setMorphReady(false);\n      return;\n    }\n    const content = context.contentRef.current;\n    if (!content) return;\n    content.dataset.invocation = String(context.invocation);\n\n    const rect = content.getBoundingClientRect();\n    const left = Math.max(\n      VIEWPORT_PADDING,\n      Math.min(\n        Math.max(context.point.x, VIEWPORT_PADDING),\n        window.innerWidth - rect.width - VIEWPORT_PADDING,\n      ),\n    );\n    const top = Math.max(\n      VIEWPORT_PADDING,\n      Math.min(\n        Math.max(context.point.y, VIEWPORT_PADDING),\n        window.innerHeight - rect.height - VIEWPORT_PADDING,\n      ),\n    );\n\n    setPosition({ x: left, y: top });\n    setSize({ width: rect.width, height: rect.height });\n    setOrigin({\n      x: clamp(context.point.x - left, 12, Math.max(12, rect.width - 12)),\n      y: clamp(context.point.y - top, 12, Math.max(12, rect.height - 12)),\n    });\n    setMorphReady(false);\n\n    if (context.reduce || context.modality === \"keyboard\") {\n      setMorphReady(true);\n      return;\n    }\n\n    // Let the measured collapsed clip paint once before expanding it. Without\n    // this preparation frame, the first invocation can batch both states and\n    // appear at full size without the morph.\n    let openFrame = 0;\n    const prepareFrame = requestAnimationFrame(() => {\n      openFrame = requestAnimationFrame(() => setMorphReady(true));\n    });\n    return () => {\n      cancelAnimationFrame(prepareFrame);\n      cancelAnimationFrame(openFrame);\n    };\n  }, [\n    context.open,\n    context.point,\n    context.contentRef,\n    context.invocation,\n    context.modality,\n    context.reduce,\n  ]);\n\n  useEffect(() => {\n    if (!context.open) return;\n    const frame = requestAnimationFrame(() => {\n      const first = getEnabledItems(context.contentRef.current)[0];\n      first?.focus({ preventScroll: true });\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [context.open, context.contentRef]);\n\n  useEffect(\n    () => () => {\n      if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);\n    },\n    [],\n  );\n\n  const moveFocus = (direction: 1 | -1) => {\n    const items = getEnabledItems(context.contentRef.current);\n    if (items.length === 0) return;\n    const current = items.indexOf(document.activeElement as HTMLElement);\n    const next = current < 0 ? 0 : (current + direction + items.length) % items.length;\n    items[next]?.focus();\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      context.setOpen(false);\n      context.triggerRef.current?.focus();\n      return;\n    }\n    if (event.key === \"Tab\") {\n      context.setOpen(false);\n      return;\n    }\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      moveFocus(event.key === \"ArrowDown\" ? 1 : -1);\n      return;\n    }\n    if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault();\n      const items = getEnabledItems(context.contentRef.current);\n      items[event.key === \"Home\" ? 0 : items.length - 1]?.focus();\n      return;\n    }\n    if (\n      event.key.length === 1 &&\n      !event.ctrlKey &&\n      !event.metaKey &&\n      !event.altKey\n    ) {\n      typeahead.current += event.key.toLocaleLowerCase();\n      if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);\n      typeaheadTimer.current = setTimeout(() => {\n        typeahead.current = \"\";\n      }, 500);\n      const match = getEnabledItems(context.contentRef.current).find((item) =>\n        (item.dataset.label ?? item.textContent ?? \"\")\n          .trim()\n          .toLocaleLowerCase()\n          .startsWith(typeahead.current),\n      );\n      match?.focus();\n    }\n  };\n\n  if (!mounted) return null;\n\n  const visualOpen = context.open && morphReady;\n  const clipHidden = collapsedClip(origin, size);\n  const clipShown = \"inset(0px 0px 0px 0px round 12px)\";\n\n  return createPortal(\n    <div\n      data-context-menu-portal=\"\"\n      aria-hidden={!context.open}\n      inert={!context.open}\n      style={{ left: position.x, top: position.y }}\n      className={cn(\n        \"fixed z-[100] [filter:drop-shadow(0_18px_28px_rgba(0,0,0,0.2))]\",\n        context.open ? \"pointer-events-auto\" : \"pointer-events-none\",\n      )}\n    >\n      <motion.div\n        ref={context.contentRef}\n        id={context.menuId}\n        role=\"menu\"\n        aria-label={ariaLabel}\n        data-morph-ready={morphReady ? \"true\" : \"false\"}\n        tabIndex={-1}\n        initial={false}\n        animate={{\n          opacity: visualOpen ? 1 : 0,\n          clipPath:\n            context.reduce || context.modality === \"keyboard\" || visualOpen\n              ? clipShown\n              : clipHidden,\n        }}\n        transition={\n          context.modality === \"keyboard\"\n            ? { duration: 0 }\n            : context.reduce\n              ? { duration: 0.1, ease: EASE_OUT }\n              : {\n                  clipPath: {\n                    duration: MORPH_DURATION,\n                    ease: EASE_OUT,\n                  },\n                  opacity: {\n                    duration: MORPH_DURATION,\n                    ease: EASE_OUT,\n                  },\n                }\n        }\n        onKeyDown={onKeyDown}\n        onContextMenu={(event) => event.preventDefault()}\n        className={cn(\n          \"min-w-56 overflow-hidden rounded-xl border border-border bg-card p-1.5 text-foreground outline-none\",\n          className,\n        )}\n      >\n        {children}\n      </motion.div>\n    </div>,\n    document.body,\n  );\n}\n\ntype ContextMenuItemTone = \"default\" | \"destructive\";\n\nexport interface ContextMenuItemProps {\n  children: ReactNode;\n  onSelect?: () => void;\n  disabled?: boolean;\n  closeOnSelect?: boolean;\n  tone?: ContextMenuItemTone;\n  inset?: boolean;\n  className?: string;\n  textValue?: string;\n}\n\nfunction ContextMenuItemBase({\n  children,\n  onSelect,\n  disabled = false,\n  closeOnSelect = true,\n  tone = \"default\",\n  inset = false,\n  className,\n  textValue,\n  role = \"menuitem\",\n  ariaChecked,\n}: ContextMenuItemProps & {\n  role?: \"menuitem\" | \"menuitemcheckbox\" | \"menuitemradio\";\n  ariaChecked?: boolean;\n}) {\n  const context = useContextMenuContext(\"ContextMenuItem\");\n  const id = useId();\n  const active = context.activeId === id;\n  const checkedProps =\n    role === \"menuitem\" ? {} : { \"aria-checked\": ariaChecked };\n\n  return (\n    <button\n      type=\"button\"\n      id={id}\n      role={role}\n      {...checkedProps}\n      disabled={disabled}\n      data-context-menu-item=\"true\"\n      data-disabled={disabled ? \"true\" : undefined}\n      data-label={textValue}\n      tabIndex={-1}\n      onFocus={() => context.setActiveId(id)}\n      onPointerMove={(event) => {\n        if (!disabled && event.pointerType !== \"touch\") event.currentTarget.focus();\n      }}\n      onClick={() => {\n        if (disabled) return;\n        onSelect?.();\n        if (closeOnSelect) context.setOpen(false);\n      }}\n      className={cn(\n        \"relative isolate flex w-full select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] outline-none\",\n        \"focus-visible:ring-2 focus-visible:ring-foreground/15\",\n        \"disabled:pointer-events-none disabled:opacity-40\",\n        inset && \"pl-8\",\n        tone === \"destructive\" ? \"text-destructive\" : \"text-foreground\",\n        className,\n      )}\n    >\n      {active ? (\n        <motion.span\n          layoutId={`${context.menuId}-active`}\n          className={cn(\n            \"absolute inset-0 -z-10 rounded-lg\",\n            tone === \"destructive\"\n              ? \"bg-destructive/10\"\n              : \"bg-foreground/[0.065]\",\n          )}\n          transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}\n        />\n      ) : null}\n      {children}\n    </button>\n  );\n}\n\nexport function ContextMenuItem(props: ContextMenuItemProps) {\n  return <ContextMenuItemBase {...props} />;\n}\n\nexport interface ContextMenuCheckboxItemProps\n  extends Omit<ContextMenuItemProps, \"onSelect\"> {\n  checked: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n}\n\nexport function ContextMenuCheckboxItem({\n  checked,\n  onCheckedChange,\n  children,\n  ...props\n}: ContextMenuCheckboxItemProps) {\n  const context = useContextMenuContext(\"ContextMenuCheckboxItem\");\n  return (\n    <ContextMenuItemBase\n      {...props}\n      role=\"menuitemcheckbox\"\n      ariaChecked={checked}\n      onSelect={() => onCheckedChange?.(!checked)}\n    >\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n        <AnimatePresence initial={false}>\n          {checked ? (\n            <motion.span\n              key=\"check\"\n              initial={context.reduce ? false : { opacity: 0, scale: 0.75 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: context.reduce ? 1 : 0.75 }}\n              transition={context.reduce ? { duration: 0.08 } : SPRING_PANEL}\n            >\n              <Check aria-hidden=\"true\" className=\"h-3.5 w-3.5\" strokeWidth={2.4} />\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </span>\n      {children}\n    </ContextMenuItemBase>\n  );\n}\n\ninterface ContextMenuRadioGroupContextValue {\n  value: string;\n  onValueChange?: (value: string) => void;\n}\n\nconst ContextMenuRadioGroupContext =\n  createContext<ContextMenuRadioGroupContextValue | null>(null);\n\nexport interface ContextMenuRadioGroupProps {\n  value: string;\n  onValueChange?: (value: string) => void;\n  children: ReactNode;\n  className?: string;\n}\n\nexport function ContextMenuRadioGroup({\n  value,\n  onValueChange,\n  children,\n  className,\n}: ContextMenuRadioGroupProps) {\n  const context = useMemo(\n    () => ({ value, onValueChange }),\n    [value, onValueChange],\n  );\n  return (\n    <ContextMenuRadioGroupContext.Provider value={context}>\n      <div className={className}>{children}</div>\n    </ContextMenuRadioGroupContext.Provider>\n  );\n}\n\nexport interface ContextMenuRadioItemProps\n  extends Omit<ContextMenuItemProps, \"onSelect\"> {\n  value: string;\n}\n\nexport function ContextMenuRadioItem({\n  value,\n  children,\n  ...props\n}: ContextMenuRadioItemProps) {\n  const group = useContext(ContextMenuRadioGroupContext);\n  if (!group) {\n    throw new Error(\n      \"ContextMenuRadioItem must be used within <ContextMenuRadioGroup>\",\n    );\n  }\n  const checked = group.value === value;\n  return (\n    <ContextMenuItemBase\n      {...props}\n      role=\"menuitemradio\"\n      ariaChecked={checked}\n      onSelect={() => group.onValueChange?.(value)}\n    >\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n        <span\n          className={cn(\n            \"h-1.5 w-1.5 rounded-full bg-current transition-opacity\",\n            checked ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </span>\n      {children}\n    </ContextMenuItemBase>\n  );\n}\n\nexport interface ContextMenuLabelProps {\n  children: ReactNode;\n  inset?: boolean;\n  className?: string;\n}\n\nexport function ContextMenuLabel({\n  children,\n  inset = false,\n  className,\n}: ContextMenuLabelProps) {\n  return (\n    <div\n      className={cn(\n        \"px-2.5 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground\",\n        inset && \"pl-8\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface ContextMenuSeparatorProps {\n  className?: string;\n}\n\nexport function ContextMenuSeparator({\n  className,\n}: ContextMenuSeparatorProps) {\n  return (\n    <hr className={cn(\"-mx-1 my-1 h-px border-0 bg-border\", className)} />\n  );\n}\n\nexport interface ContextMenuShortcutProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function ContextMenuShortcut({\n  children,\n  className,\n}: ContextMenuShortcutProps) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"ml-auto pl-4 text-[10px] font-medium tracking-wide text-muted-foreground\",\n        className,\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/context-menu.preview.tsx","type":"preview","content":"\"use client\";\n\nimport {\n  Check,\n  Copy,\n  Download,\n  Eye,\n  Pencil,\n  Trash2,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport {\n  ContextMenu,\n  ContextMenuCheckboxItem,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuTrigger,\n} from \"@/components/motion/context-menu\";\nimport { SPRING_SWAP } from \"@/lib/ease\";\n\nexport function ContextMenuPreview() {\n  const reduce = useReducedMotion() ?? false;\n  const [message, setMessage] = useState<string | null>(null);\n  const [offline, setOffline] = useState(false);\n\n  return (\n    <div className=\"flex min-h-[360px] w-full items-center justify-center\">\n      <ContextMenu>\n        <ContextMenuTrigger>\n          <button\n            type=\"button\"\n            className=\"group flex flex-col items-center outline-none\"\n          >\n            <div className=\"relative h-24 w-32 transition-transform duration-150 group-active:scale-[0.98] group-focus-visible:rounded-2xl group-focus-visible:ring-2 group-focus-visible:ring-foreground/20 group-focus-visible:ring-offset-4 group-focus-visible:ring-offset-background\">\n              <div className=\"absolute left-1 top-1 h-7 w-14 rounded-t-[10px] bg-[#d4a84f] dark:bg-[#a77d2f]\" />\n              <div className=\"absolute inset-x-0 bottom-0 top-5 rounded-[14px] bg-[#e7bb61] shadow-[0_14px_24px_-16px_rgba(90,58,8,0.75)] dark:bg-[#bd8d36]\" />\n              <div className=\"absolute inset-x-0 bottom-0 top-9 rounded-[14px] bg-[#efc86f] dark:bg-[#cb9a41]\" />\n              <div className=\"absolute inset-x-5 bottom-4 h-px bg-black/10 dark:bg-white/10\" />\n            </div>\n\n            <span className=\"mt-4 text-sm font-medium text-foreground\">\n              Right click on me\n            </span>\n\n            <div className=\"mt-1 h-4\">\n              <AnimatePresence mode=\"wait\" initial={false}>\n                {message ? (\n                  <motion.span\n                    key={message}\n                    initial={\n                      reduce\n                        ? { opacity: 0 }\n                        : { opacity: 0, y: 3, filter: \"blur(2px)\" }\n                    }\n                    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                    exit={\n                      reduce\n                        ? { opacity: 0 }\n                        : { opacity: 0, y: -2, filter: \"blur(2px)\" }\n                    }\n                    transition={reduce ? { duration: 0.1 } : SPRING_SWAP}\n                    className=\"flex items-center gap-1 text-[10px] text-muted-foreground\"\n                  >\n                    <Check aria-hidden=\"true\" className=\"h-3 w-3 text-success\" />\n                    {message}\n                  </motion.span>\n                ) : (\n                  <span className=\"text-[10px] text-muted-foreground\">\n                    or long-press · Shift + F10\n                  </span>\n                )}\n              </AnimatePresence>\n            </div>\n          </button>\n        </ContextMenuTrigger>\n\n        <ContextMenuContent ariaLabel=\"Folder actions\" className=\"w-60\">\n          <ContextMenuLabel>Project files</ContextMenuLabel>\n          <ContextMenuItem\n            textValue=\"Open\"\n            onSelect={() => setMessage(\"Folder opened\")}\n          >\n            <Eye aria-hidden=\"true\" className=\"h-4 w-4\" />\n            Open\n            <ContextMenuShortcut>↵</ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem\n            textValue=\"Rename\"\n            onSelect={() => setMessage(\"Ready to rename\")}\n          >\n            <Pencil aria-hidden=\"true\" className=\"h-4 w-4\" />\n            Rename\n            <ContextMenuShortcut>R</ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem\n            textValue=\"Duplicate\"\n            onSelect={() => setMessage(\"Folder duplicated\")}\n          >\n            <Copy aria-hidden=\"true\" className=\"h-4 w-4\" />\n            Duplicate\n            <ContextMenuShortcut>⌘D</ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem\n            textValue=\"Download\"\n            onSelect={() => setMessage(\"Download started\")}\n          >\n            <Download aria-hidden=\"true\" className=\"h-4 w-4\" />\n            Download\n          </ContextMenuItem>\n\n          <ContextMenuSeparator />\n\n          <ContextMenuCheckboxItem\n            textValue=\"Keep offline\"\n            checked={offline}\n            closeOnSelect={false}\n            onCheckedChange={(checked) => {\n              setOffline(checked);\n              setMessage(checked ? \"Available offline\" : \"Online only\");\n            }}\n          >\n            Keep offline\n          </ContextMenuCheckboxItem>\n\n          <ContextMenuSeparator />\n\n          <ContextMenuItem\n            tone=\"destructive\"\n            textValue=\"Move to trash\"\n            onSelect={() => setMessage(\"Moved to trash\")}\n          >\n            <Trash2 aria-hidden=\"true\" className=\"h-4 w-4\" />\n            Move to trash\n            <ContextMenuShortcut>⌘⌫</ContextMenuShortcut>\n          </ContextMenuItem>\n        </ContextMenuContent>\n      </ContextMenu>\n    </div>\n  );\n}\n"}]}