{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-lens",
  "type": "registry:ui",
  "title": "Cursor lens",
  "description": "Compares two states of the same media through a movable detail lens.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "cards-media"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/cursor-lens/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Pointer and keyboard controlled comparison lens",
    "sceneFamily": "editorial-warm",
    "motionRole": "gentle",
    "primaryState": "Comparison lens settled on media detail",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/cursor-lens.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/cursor-lens.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\n\nconst FOLLOW = { stiffness: 360, damping: 30, mass: 0.45 } as const;\n\nexport type CursorLensProps = {\n  base: ReactNode;\n  detail: ReactNode;\n  label: string;\n  instructions?: string;\n  size?: number;\n  zoom?: number;\n  className?: string;\n};\n\nexport function CursorLens({\n  base,\n  detail,\n  label,\n  instructions = \"Use the arrow keys to move the lens. Press Escape to hide it.\",\n  size = 132,\n  zoom = 1.35,\n  className = \"\",\n}: CursorLensProps) {\n  const resolvedSize = Number.isFinite(size) ? Math.max(44, size) : 132;\n  const resolvedZoom = Number.isFinite(zoom) ? Math.max(1, zoom) : 1.35;\n  const root = useRef<HTMLDivElement>(null);\n  const pointerFocus = useRef(false);\n  const touchGesture = useRef<{\n    pointerId: number;\n    startX: number;\n    startY: number;\n    moved: boolean;\n  } | null>(null);\n  const [hovered, setHovered] = useState(false);\n  const [touchPinned, setTouchPinned] = useState(false);\n  const [keyboardVisible, setKeyboardVisible] = useState(false);\n  const [rootSize, setRootSize] = useState({ width: 0, height: 0 });\n  const visible = hovered || touchPinned || keyboardVisible;\n  const reduced = useReducedMotion() === true;\n  const rawX = useMotionValue(0);\n  const rawY = useMotionValue(0);\n  const x = useSpring(rawX, FOLLOW);\n  const y = useSpring(rawY, FOLLOW);\n  const springTransform = useTransform([x, y], ([latestX, latestY]) =>\n    `translate3d(${Number(latestX) - resolvedSize / 2}px, ${Number(latestY) - resolvedSize / 2}px, 0)`,\n  );\n  const rawTransform = useTransform([rawX, rawY], ([latestX, latestY]) =>\n    `translate3d(${Number(latestX) - resolvedSize / 2}px, ${Number(latestY) - resolvedSize / 2}px, 0)`,\n  );\n  const springDetailTransform = useTransform([x, y], ([latestX, latestY]) =>\n    `translate3d(${resolvedSize / 2 - Number(latestX) * resolvedZoom}px, ${resolvedSize / 2 - Number(latestY) * resolvedZoom}px, 0) scale(${resolvedZoom})`,\n  );\n  const rawDetailTransform = useTransform([rawX, rawY], ([latestX, latestY]) =>\n    `translate3d(${resolvedSize / 2 - Number(latestX) * resolvedZoom}px, ${resolvedSize / 2 - Number(latestY) * resolvedZoom}px, 0) scale(${resolvedZoom})`,\n  );\n  const transform = reduced ? rawTransform : springTransform;\n  const detailTransform = reduced ? rawDetailTransform : springDetailTransform;\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node) return;\n\n    const measure = () => {\n      const next = { width: node.clientWidth, height: node.clientHeight };\n      setRootSize((current) => current.width === next.width && current.height === next.height ? current : next);\n    };\n\n    measure();\n    if (typeof ResizeObserver === \"undefined\") {\n      window.addEventListener(\"resize\", measure);\n      return () => window.removeEventListener(\"resize\", measure);\n    }\n\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const locate = (clientX: number, clientY: number) => {\n    const box = root.current?.getBoundingClientRect();\n    if (!box) return;\n    rawX.set(Math.max(0, Math.min(box.width, clientX - box.left)));\n    rawY.set(Math.max(0, Math.min(box.height, clientY - box.top)));\n  };\n\n  const pointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    locate(event.clientX, event.clientY);\n    if (event.pointerType !== \"touch\") {\n      setHovered(true);\n      return;\n    }\n    const gesture = touchGesture.current;\n    if (\n      gesture &&\n      gesture.pointerId === event.pointerId &&\n      Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > 8\n    ) {\n      gesture.moved = true;\n    }\n  };\n\n  const keyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const step = event.shiftKey ? 24 : 10;\n    const box = root.current?.getBoundingClientRect();\n    if (!box) return;\n    let nextX = rawX.get() || box.width / 2;\n    let nextY = rawY.get() || box.height / 2;\n    if (event.key === \"ArrowLeft\") nextX -= step;\n    else if (event.key === \"ArrowRight\") nextX += step;\n    else if (event.key === \"ArrowUp\") nextY -= step;\n    else if (event.key === \"ArrowDown\") nextY += step;\n    else if (event.key === \"Escape\") {\n      setHovered(false);\n      setTouchPinned(false);\n      setKeyboardVisible(false);\n      return;\n    }\n    else return;\n    event.preventDefault();\n    rawX.set(Math.max(0, Math.min(box.width, nextX)));\n    rawY.set(Math.max(0, Math.min(box.height, nextY)));\n    setKeyboardVisible(true);\n  };\n\n  return (\n    <div\n      ref={root}\n      role=\"group\"\n      tabIndex={0}\n      aria-label={label}\n      onPointerEnter={pointerMove}\n      onPointerMove={pointerMove}\n      onPointerLeave={(event) => {\n        if (event.pointerType !== \"touch\") setHovered(false);\n      }}\n      onPointerDown={(event) => {\n        pointerFocus.current = true;\n        if (event.pointerType === \"touch\") {\n          locate(event.clientX, event.clientY);\n          touchGesture.current = {\n            pointerId: event.pointerId,\n            startX: event.clientX,\n            startY: event.clientY,\n            moved: false,\n          };\n        }\n      }}\n      onPointerUp={(event) => {\n        pointerFocus.current = false;\n        if (event.pointerType !== \"touch\") return;\n        const gesture = touchGesture.current;\n        touchGesture.current = null;\n        if (!gesture || gesture.pointerId !== event.pointerId || gesture.moved) return;\n        setKeyboardVisible(false);\n        setTouchPinned((pinned) => !pinned);\n      }}\n      onPointerCancel={(event) => {\n        pointerFocus.current = false;\n        if (touchGesture.current?.pointerId === event.pointerId) {\n          touchGesture.current = null;\n        }\n      }}\n      onFocus={() => {\n        if (pointerFocus.current) return;\n        const box = root.current?.getBoundingClientRect();\n        if (box) { rawX.set(box.width / 2); rawY.set(box.height / 2); }\n      }}\n      onBlur={() => {\n        pointerFocus.current = false;\n        touchGesture.current = null;\n        setHovered(false);\n        setTouchPinned(false);\n        setKeyboardVisible(false);\n      }}\n      onKeyDown={keyDown}\n      className={`relative isolate min-h-[240px] w-full overflow-hidden rounded-[10px] outline-none focus-visible:shadow-[0_0_0_3px_rgba(69,104,255,.28)] ${className}`}\n      style={{ touchAction: \"pan-y\" }}\n    >\n      <div className=\"absolute inset-0\">{base}</div>\n      <AnimatePresence>\n        {visible ? (\n          <motion.div\n            data-cursor-lens\n            data-position-mode={reduced ? \"instant\" : \"spring\"}\n            aria-hidden\n            initial={reduced ? { opacity: 1 } : { opacity: 0, transform: `${transform.get()} scale(.96)` }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={reduced ? { duration: 0 } : { duration: 0.16, ease: [0.23, 1, 0.32, 1] }}\n            className=\"pointer-events-none absolute left-0 top-0 z-10 overflow-hidden rounded-full border border-white/70 bg-white shadow-[0_4px_8px_-6px_rgba(28,25,23,.72),inset_0_0_0_1px_rgba(41,41,41,.1)] dark:border-white/25 dark:bg-[#202020]\"\n            style={{ width: resolvedSize, height: resolvedSize, transform }}\n          >\n            <motion.div\n              data-cursor-lens-detail\n              className=\"absolute left-0 top-0 origin-top-left\"\n              style={{\n                transform: detailTransform,\n                width: rootSize.width || \"100%\",\n                height: rootSize.height || \"100%\",\n              }}\n            >\n              {detail}\n            </motion.div>\n            <span className=\"absolute inset-[5px] rounded-full border border-black/10 dark:border-white/10\" />\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n      <span className=\"sr-only\">{instructions}</span>\n    </div>\n  );\n}\n"
    }
  ]
}
