{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-cursor",
  "title": "Context Cursor",
  "description": "A scoped custom cursor for pointer-rich surfaces where each target can register its own label, icon, and interaction intent.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/context-cursor.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  type MotionValue,\n  type SpringOptions,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ContextCursorVariant = \"default\" | \"open\" | \"drag\" | \"preview\";\nexport type ContextCursorFollow = \"instant\" | \"spring\";\n\nexport type ContextCursorState = {\n  label: React.ReactNode;\n  icon?: React.ReactNode;\n  variant?: ContextCursorVariant;\n};\n\nexport type ContextCursorAnimation = {\n  edgeFade?: boolean;\n  edgeFadeDistance?: number;\n  edgeFadeEasing?: (progress: number) => number;\n  hideDelay?: number;\n  opacity?: {\n    hidden?: number;\n    visible?: number;\n  };\n  scale?: {\n    hidden?: number;\n    visible?: number;\n  };\n  opacitySpring?: SpringOptions;\n  scaleSpring?: SpringOptions;\n};\n\nexport type ContextCursorTargetAnimation = Omit<\n  ContextCursorAnimation,\n  \"opacitySpring\" | \"scaleSpring\"\n>;\n\ntype ContextCursorContextValue = {\n  showCursor: (\n    cursor: ContextCursorState,\n    targetId: string,\n    point?: CursorPoint,\n    targetBounds?: DOMRectReadOnly,\n    targetAnimation?: ContextCursorTargetAnimation,\n  ) => void;\n  hideCursor: (targetId?: string, point?: CursorPoint) => void;\n  isDisabled: boolean;\n};\n\ntype CursorPoint = {\n  x: number;\n  y: number;\n};\n\ntype ResolvedContextCursorAnimation = {\n  edgeFade: boolean;\n  edgeFadeDistance: number;\n  edgeFadeEasing: (progress: number) => number;\n  hideDelay: number;\n  hiddenOpacity: number;\n  visibleOpacity: number;\n  hiddenScale: number;\n  visibleScale: number;\n};\n\nconst ContextCursorContext =\n  React.createContext<ContextCursorContextValue | null>(null);\n\nconst defaultSpring: SpringOptions = {\n  mass: 0.1,\n  stiffness: 320,\n  damping: 26,\n};\n\nconst opacitySpring: SpringOptions = {\n  mass: 0.1,\n  stiffness: 480,\n  damping: 24,\n};\n\nconst scaleSpring: SpringOptions = {\n  mass: 0.12,\n  stiffness: 420,\n  damping: 22,\n};\n\nconst nativeCursorStyleId = \"context-cursor-native-hidden\";\nconst defaultEdgeFadeDistance = 40;\nconst defaultCursorHideDelay = 140;\nconst defaultHiddenOpacity = 0;\nconst defaultVisibleOpacity = 1;\nconst defaultHiddenScale = 0.4;\nconst defaultVisibleScale = 1;\n// Once the edge-fade has shrunk the badge below this much progress it is\n// effectively gone, so the native cursor is handed back at that exact point.\nconst nativeHandoffProgress = 0.08;\n\nconst variantClassNames: Record<ContextCursorVariant, string> = {\n  default: \"border-border bg-background text-foreground\",\n  open: \"border-transparent bg-primary text-primary-foreground\",\n  drag: \"border-border bg-muted text-foreground\",\n  preview: \"border-border bg-background text-foreground\",\n};\n\nexport type ContextCursorProps = Omit<\n  React.ComponentPropsWithoutRef<\"div\">,\n  \"onPointerEnter\" | \"onPointerLeave\" | \"onPointerMove\"\n> & {\n  follow?: ContextCursorFollow;\n  spring?: SpringOptions;\n  animation?: ContextCursorAnimation;\n  edgeFadeDistance?: number;\n  cursorClassName?: string;\n  disabled?: boolean;\n};\n\nexport function ContextCursor({\n  children,\n  className,\n  cursorClassName,\n  follow = \"instant\",\n  spring = defaultSpring,\n  animation,\n  edgeFadeDistance = defaultEdgeFadeDistance,\n  disabled,\n  ...props\n}: ContextCursorProps) {\n  const initialHiddenOpacity =\n    animation?.opacity?.hidden ?? defaultHiddenOpacity;\n  const initialHiddenScale = animation?.scale?.hidden ?? defaultHiddenScale;\n  const shouldReduceMotion = useReducedMotion();\n  const supportsFinePointer = useFinePointer();\n  const [cursor, setCursor] = React.useState<ContextCursorState | null>(null);\n  const wrapperRef = React.useRef<HTMLDivElement>(null);\n  const bounds = React.useRef<DOMRectReadOnly | null>(null);\n  const activeTargetBounds = React.useRef<DOMRectReadOnly | null>(null);\n  const activeTargetAnimation =\n    React.useRef<ContextCursorTargetAnimation | null>(null);\n  const hideTimer = React.useRef<number | null>(null);\n  const pointerFrame = React.useRef<number | null>(null);\n  const latestPointerFrame = React.useRef<{\n    point: CursorPoint;\n    wrapperBounds: DOMRectReadOnly;\n    targetBounds: DOMRectReadOnly | null;\n  } | null>(null);\n  const hasNativeCursorLock = React.useRef(false);\n  const activeTargetId = React.useRef<string | null>(null);\n  const isDisabled = Boolean(\n    disabled || shouldReduceMotion || !supportsFinePointer,\n  );\n  const rawX = useMotionValue(0);\n  const rawY = useMotionValue(0);\n  const opacity = useSpring(\n    initialHiddenOpacity,\n    animation?.opacitySpring ?? opacitySpring,\n  );\n  const scale = useSpring(\n    initialHiddenScale,\n    animation?.scaleSpring ?? scaleSpring,\n  );\n\n  const hideNativeCursor = React.useCallback(() => {\n    if (hasNativeCursorLock.current) return;\n\n    hasNativeCursorLock.current = true;\n    acquireNativeCursorLock(wrapperRef.current);\n  }, []);\n\n  const showNativeCursor = React.useCallback(() => {\n    if (!hasNativeCursorLock.current) return;\n\n    hasNativeCursorLock.current = false;\n    releaseNativeCursorLock(wrapperRef.current);\n  }, []);\n\n  const getAnimation = React.useCallback(\n    (\n      targetAnimation: ContextCursorTargetAnimation | null =\n        activeTargetAnimation.current,\n    ): ResolvedContextCursorAnimation => ({\n      edgeFade: targetAnimation?.edgeFade ?? animation?.edgeFade ?? true,\n      edgeFadeDistance:\n        targetAnimation?.edgeFadeDistance ??\n        animation?.edgeFadeDistance ??\n        edgeFadeDistance,\n      edgeFadeEasing:\n        targetAnimation?.edgeFadeEasing ??\n        animation?.edgeFadeEasing ??\n        smoothstep,\n      hideDelay:\n        targetAnimation?.hideDelay ??\n        animation?.hideDelay ??\n        defaultCursorHideDelay,\n      hiddenOpacity:\n        targetAnimation?.opacity?.hidden ??\n        animation?.opacity?.hidden ??\n        defaultHiddenOpacity,\n      visibleOpacity:\n        targetAnimation?.opacity?.visible ??\n        animation?.opacity?.visible ??\n        defaultVisibleOpacity,\n      hiddenScale:\n        targetAnimation?.scale?.hidden ??\n        animation?.scale?.hidden ??\n        defaultHiddenScale,\n      visibleScale:\n        targetAnimation?.scale?.visible ??\n        animation?.scale?.visible ??\n        defaultVisibleScale,\n    }),\n    [animation, edgeFadeDistance],\n  );\n\n  // Drives the badge by how close the pointer is to the target edge: it shrinks\n  // and fades as the edge nears, and the native cursor is handed back at the\n  // exact moment the badge vanishes — so the two never overlap and there is no\n  // gap where neither cursor is visible.\n  //\n  // Scale and opacity are written with `jump` (not `set`), so the badge size is\n  // an exact function of the pointer's distance to the border with zero spring\n  // lag. A laggy spring can't keep up with a fast pointer, which is what made\n  // the badge finish shrinking *outside* the target; jumping ties the shrink to\n  // position, so it always completes inside the box no matter how fast the move.\n  const updateCursorPresence = React.useCallback(\n    (point: CursorPoint, targetBounds: DOMRectReadOnly) => {\n      const currentAnimation = getAnimation();\n      const distanceToEdge = Math.min(\n        point.x - targetBounds.left,\n        targetBounds.right - point.x,\n        point.y - targetBounds.top,\n        targetBounds.bottom - point.y,\n      );\n      const fadeDistance = Math.max(1, currentAnimation.edgeFadeDistance);\n      const edgeProgress = clamp(distanceToEdge / fadeDistance, 0, 1);\n      const easedProgress = currentAnimation.edgeFade\n        ? clamp(currentAnimation.edgeFadeEasing(edgeProgress), 0, 1)\n        : 1;\n\n      opacity.jump(\n        interpolate(\n          currentAnimation.hiddenOpacity,\n          currentAnimation.visibleOpacity,\n          easedProgress,\n        ),\n      );\n      scale.jump(\n        interpolate(\n          currentAnimation.hiddenScale,\n          currentAnimation.visibleScale,\n          easedProgress,\n        ),\n      );\n\n      if (easedProgress <= nativeHandoffProgress) {\n        showNativeCursor();\n      } else {\n        hideNativeCursor();\n      }\n    },\n    [getAnimation, hideNativeCursor, opacity, scale, showNativeCursor],\n  );\n\n  const getWrapperBounds = React.useCallback(() => {\n    const currentBounds =\n      wrapperRef.current?.getBoundingClientRect() ?? bounds.current ?? null;\n\n    if (currentBounds) {\n      bounds.current = currentBounds;\n    }\n\n    return currentBounds;\n  }, []);\n\n  const updatePointerPosition = React.useCallback(\n    (\n      point: CursorPoint,\n      wrapperBounds: DOMRectReadOnly,\n      targetBounds: DOMRectReadOnly | null = activeTargetBounds.current,\n    ) => {\n      rawX.set(snapToDevicePixel(point.x - wrapperBounds.left));\n      rawY.set(snapToDevicePixel(point.y - wrapperBounds.top));\n\n      if (targetBounds) {\n        updateCursorPresence(point, targetBounds);\n      }\n    },\n    [rawX, rawY, updateCursorPresence],\n  );\n\n  const schedulePointerPosition = React.useCallback(\n    (\n      point: CursorPoint,\n      wrapperBounds: DOMRectReadOnly,\n      targetBounds: DOMRectReadOnly | null = activeTargetBounds.current,\n    ) => {\n      latestPointerFrame.current = {\n        point,\n        wrapperBounds,\n        targetBounds,\n      };\n\n      if (pointerFrame.current !== null) return;\n\n      pointerFrame.current = window.requestAnimationFrame(() => {\n        pointerFrame.current = null;\n        const nextFrame = latestPointerFrame.current;\n        latestPointerFrame.current = null;\n\n        if (!nextFrame) return;\n\n        updatePointerPosition(\n          nextFrame.point,\n          nextFrame.wrapperBounds,\n          nextFrame.targetBounds,\n        );\n      });\n    },\n    [updatePointerPosition],\n  );\n\n  const showCursor = React.useCallback(\n    (\n      nextCursor: ContextCursorState,\n      targetId: string,\n      point?: CursorPoint,\n      targetBounds?: DOMRectReadOnly,\n      targetAnimation?: ContextCursorTargetAnimation,\n    ) => {\n      if (isDisabled) return;\n\n      if (hideTimer.current) {\n        window.clearTimeout(hideTimer.current);\n        hideTimer.current = null;\n      }\n\n      activeTargetId.current = targetId;\n      activeTargetBounds.current = targetBounds ?? null;\n      activeTargetAnimation.current = targetAnimation ?? null;\n      setCursor(nextCursor);\n\n      const currentWrapperBounds = getWrapperBounds();\n      if (point && currentWrapperBounds) {\n        updatePointerPosition(point, currentWrapperBounds, targetBounds ?? null);\n      }\n\n      if (!point || !targetBounds) {\n        hideNativeCursor();\n        const currentAnimation = getAnimation(targetAnimation ?? null);\n        opacity.set(currentAnimation.visibleOpacity);\n        scale.set(currentAnimation.visibleScale);\n      }\n    },\n    [\n      hideNativeCursor,\n      getWrapperBounds,\n      isDisabled,\n      opacity,\n      scale,\n      getAnimation,\n      updatePointerPosition,\n    ],\n  );\n\n  const hideCursor = React.useCallback(\n    (targetId?: string, point?: CursorPoint) => {\n      const targetIdAtLeave = targetId ?? activeTargetId.current;\n      if (targetIdAtLeave && activeTargetId.current !== targetIdAtLeave) {\n        return;\n      }\n\n      const currentAnimation = getAnimation(activeTargetAnimation.current);\n      activeTargetBounds.current = null;\n      activeTargetAnimation.current = null;\n\n      if (hideTimer.current) {\n        window.clearTimeout(hideTimer.current);\n        hideTimer.current = null;\n      }\n\n      const currentBounds = bounds.current;\n      if (point && currentBounds) {\n        updatePointerPosition(point, currentBounds, null);\n      }\n\n      // Leaving the target snaps the badge away (no spring tail) and hands the\n      // native cursor back on the same frame — so crossing the border reads as\n      // an instant switch back to the normal cursor, never a badge shrinking\n      // outside the box on a fast exit that skipped the edge-fade band.\n      opacity.jump(currentAnimation.hiddenOpacity);\n      scale.jump(currentAnimation.hiddenScale);\n      showNativeCursor();\n\n      // The badge is already invisible; this only clears the React state once\n      // we're sure the pointer hasn't re-entered another target in the meantime.\n      hideTimer.current = window.setTimeout(() => {\n        if (targetIdAtLeave && activeTargetId.current !== targetIdAtLeave) {\n          return;\n        }\n\n        activeTargetId.current = null;\n        setCursor(null);\n        hideTimer.current = null;\n      }, currentAnimation.hideDelay);\n    },\n    [getAnimation, opacity, scale, showNativeCursor, updatePointerPosition],\n  );\n\n  React.useEffect(() => {\n    return () => {\n      if (hideTimer.current) {\n        window.clearTimeout(hideTimer.current);\n      }\n      if (pointerFrame.current !== null) {\n        window.cancelAnimationFrame(pointerFrame.current);\n      }\n      showNativeCursor();\n    };\n  }, [showNativeCursor]);\n\n  React.useEffect(() => {\n    if (!isDisabled) return;\n\n    if (hideTimer.current) {\n      window.clearTimeout(hideTimer.current);\n      hideTimer.current = null;\n    }\n\n    if (pointerFrame.current !== null) {\n      window.cancelAnimationFrame(pointerFrame.current);\n      pointerFrame.current = null;\n    }\n    latestPointerFrame.current = null;\n    activeTargetId.current = null;\n    activeTargetBounds.current = null;\n    activeTargetAnimation.current = null;\n    bounds.current = null;\n    const currentAnimation = getAnimation(null);\n    opacity.set(currentAnimation.hiddenOpacity);\n    scale.set(currentAnimation.hiddenScale);\n    showNativeCursor();\n\n    hideTimer.current = window.setTimeout(() => {\n      setCursor(null);\n      hideTimer.current = null;\n    }, 0);\n  }, [getAnimation, isDisabled, opacity, scale, showNativeCursor]);\n\n  const updateBounds = React.useCallback((element: HTMLDivElement) => {\n    bounds.current = element.getBoundingClientRect();\n  }, []);\n\n  const handlePointerEnter = React.useCallback(\n    (event: React.PointerEvent<HTMLDivElement>) => {\n      if (isDisabled || event.pointerType !== \"mouse\") return;\n\n      updateBounds(event.currentTarget);\n    },\n    [isDisabled, updateBounds],\n  );\n\n  const handlePointerMove = React.useCallback(\n    (event: React.PointerEvent<HTMLDivElement>) => {\n      if (isDisabled || event.pointerType !== \"mouse\") return;\n\n      const currentBounds =\n        bounds.current ?? event.currentTarget.getBoundingClientRect();\n\n      schedulePointerPosition(\n        { x: event.clientX, y: event.clientY },\n        currentBounds,\n      );\n    },\n    [isDisabled, schedulePointerPosition],\n  );\n\n  const handlePointerLeave = React.useCallback(\n    (event: React.PointerEvent<HTMLDivElement>) => {\n      if (event.pointerType !== \"mouse\") return;\n\n      hideCursor(undefined, { x: event.clientX, y: event.clientY });\n      bounds.current = null;\n    },\n    [hideCursor],\n  );\n\n  const handlePointerCancel = React.useCallback(() => {\n    bounds.current = null;\n    hideCursor();\n  }, [hideCursor]);\n\n  const contextValue = React.useMemo<ContextCursorContextValue>(\n    () => ({\n      showCursor,\n      hideCursor,\n      isDisabled,\n    }),\n    [hideCursor, isDisabled, showCursor],\n  );\n  const variant = cursor?.variant ?? \"default\";\n\n  return (\n    <ContextCursorContext.Provider value={contextValue}>\n      <div\n        {...props}\n        ref={wrapperRef}\n        data-slot=\"context-cursor\"\n        className={cn(\"relative\", className)}\n        onPointerEnter={handlePointerEnter}\n        onPointerMove={handlePointerMove}\n        onPointerLeave={handlePointerLeave}\n        onPointerCancel={handlePointerCancel}\n      >\n        {children}\n        {follow === \"spring\" ? (\n          <SpringCursorIndicator\n            cursor={cursor}\n            cursorClassName={cursorClassName}\n            opacity={opacity}\n            rawX={rawX}\n            rawY={rawY}\n            scale={scale}\n            spring={spring}\n            variant={variant}\n          />\n        ) : (\n          <ContextCursorIndicator\n            cursor={cursor}\n            cursorClassName={cursorClassName}\n            opacity={opacity}\n            scale={scale}\n            variant={variant}\n            x={rawX}\n            y={rawY}\n          />\n        )}\n      </div>\n    </ContextCursorContext.Provider>\n  );\n}\n\ntype ContextCursorIndicatorProps = {\n  cursor: ContextCursorState | null;\n  cursorClassName?: string;\n  opacity: MotionValue<number>;\n  scale: MotionValue<number>;\n  variant: ContextCursorVariant;\n  x: MotionValue<number>;\n  y: MotionValue<number>;\n};\n\nfunction ContextCursorIndicator({\n  cursor,\n  cursorClassName,\n  opacity,\n  scale,\n  variant,\n  x,\n  y,\n}: ContextCursorIndicatorProps) {\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      data-slot=\"context-cursor-indicator\"\n      style={{\n        x,\n        y,\n        opacity,\n        scale,\n        willChange: \"transform, opacity\",\n      }}\n      className={cn(\n        // Anchored to the pointer coordinate; the badge inside is centered on it\n        // so it reads as the cursor itself, and the scale pops from that point.\n        \"pointer-events-none absolute left-0 top-0 z-50 origin-center will-change-transform\",\n        !cursor && \"invisible\",\n        cursorClassName,\n      )}\n    >\n      <span\n        data-slot=\"context-cursor-label\"\n        className={cn(\n          \"inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-medium leading-5 shadow-sm\",\n          variantClassNames[variant],\n        )}\n      >\n        {cursor?.icon ? (\n          <span\n            data-slot=\"context-cursor-icon\"\n            className=\"flex size-3.5 items-center justify-center\"\n          >\n            {cursor.icon}\n          </span>\n        ) : null}\n        {cursor?.label}\n      </span>\n    </motion.div>\n  );\n}\n\ntype SpringCursorIndicatorProps = Omit<\n  ContextCursorIndicatorProps,\n  \"x\" | \"y\"\n> & {\n  rawX: MotionValue<number>;\n  rawY: MotionValue<number>;\n  spring: SpringOptions;\n};\n\nfunction SpringCursorIndicator({\n  rawX,\n  rawY,\n  spring,\n  ...props\n}: SpringCursorIndicatorProps) {\n  const x = useSpring(rawX, spring);\n  const y = useSpring(rawY, spring);\n\n  return <ContextCursorIndicator {...props} x={x} y={y} />;\n}\n\nfunction useFinePointer() {\n  const [supportsFinePointer, setSupportsFinePointer] = React.useState(false);\n\n  React.useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const updateSupportsFinePointer = () => {\n      setSupportsFinePointer(mediaQuery.matches);\n    };\n\n    updateSupportsFinePointer();\n    mediaQuery.addEventListener(\"change\", updateSupportsFinePointer);\n\n    return () => {\n      mediaQuery.removeEventListener(\"change\", updateSupportsFinePointer);\n    };\n  }, []);\n\n  return supportsFinePointer;\n}\n\nfunction acquireNativeCursorLock(element: HTMLElement | null) {\n  if (!element) return;\n\n  ensureNativeCursorStyle();\n  element.dataset.contextCursorNativeHidden = \"true\";\n}\n\nfunction releaseNativeCursorLock(element: HTMLElement | null) {\n  if (!element) return;\n\n  delete element.dataset.contextCursorNativeHidden;\n}\n\nfunction ensureNativeCursorStyle() {\n  if (document.getElementById(nativeCursorStyleId)) return;\n\n  const style = document.createElement(\"style\");\n  style.id = nativeCursorStyleId;\n  style.textContent = `\n    [data-context-cursor-native-hidden=\"true\"],\n    [data-context-cursor-native-hidden=\"true\"] * {\n      cursor: none !important;\n    }\n  `;\n  document.head.appendChild(style);\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction smoothstep(value: number) {\n  return value * value * (3 - 2 * value);\n}\n\nfunction interpolate(from: number, to: number, progress: number) {\n  return from + (to - from) * progress;\n}\n\nfunction snapToDevicePixel(value: number) {\n  const ratio = window.devicePixelRatio || 1;\n\n  return Math.round(value * ratio) / ratio;\n}\n\nexport type ContextCursorTargetProps =\n  React.ComponentPropsWithoutRef<\"div\"> & {\n    label: React.ReactNode;\n    icon?: React.ReactNode;\n    variant?: ContextCursorVariant;\n    animation?: ContextCursorTargetAnimation;\n  };\n\nexport function ContextCursorTarget({\n  children,\n  className,\n  label,\n  icon,\n  variant = \"default\",\n  animation,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: ContextCursorTargetProps) {\n  const context = React.useContext(ContextCursorContext);\n  const targetId = React.useId();\n\n  return (\n    <div\n      data-slot=\"context-cursor-target\"\n      className={className}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") {\n          context?.showCursor(\n            { label, icon, variant },\n            targetId,\n            {\n              x: event.clientX,\n              y: event.clientY,\n            },\n            event.currentTarget.getBoundingClientRect(),\n            animation,\n          );\n        }\n        onPointerEnter?.(event);\n      }}\n      onPointerLeave={(event) => {\n        context?.hideCursor(targetId, {\n          x: event.clientX,\n          y: event.clientY,\n        });\n        onPointerLeave?.(event);\n      }}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/context-cursor.tsx"
    }
  ],
  "meta": {
    "tags": [
      "pointer",
      "mouse",
      "context-label",
      "drag",
      "preview"
    ],
    "effects": [
      "spring-follow",
      "cursor-fade"
    ]
  },
  "categories": [
    "cursor"
  ],
  "type": "registry:ui"
}