{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "zoom-spotlight",
  "title": "Zoom Spotlight",
  "description": "An animated zoom spotlight for React and shadcn, pushing in on one region of live UI and dimming the rest.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-demo-core.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/zoom-spotlight/zoom-spotlight.tsx",
      "content": "\"use client\";\n\nimport { animate, motion, useMotionValue } from \"motion/react\";\nimport { useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { localRect, resolveElement, targetKey, type DemoTarget } from \"../_primitives/demo-core/demo-core\";\nimport { duration, ease, exit, reducedFade, smooth, useSafeReducedMotion } from \"../lib/motion\";\n\nexport type ZoomSpotlightProps = {\n  /** Zoom onto the target and dim the rest. Turning it off springs back to the whole view. */\n  active: boolean;\n  /** The region to bring into focus: an element by ref, or a `data-demo` name inside the children. */\n  target?: DemoTarget | null;\n  /** The most it zooms. It zooms less when the target would not fit. Default 1.6. */\n  scale?: number;\n  /** Space between the target and the edge of the lit area, in px. Default 8. */\n  padding?: number;\n  /** Dim everything outside the target. Default true. */\n  dim?: boolean;\n  /** Classes for the frame: give it a size (`flex-1 min-h-0`, `h-96`); it clips what the zoom pushes out. */\n  className?: string;\n  children: ReactNode;\n};\n\n/** The lit area keeps this much of the frame around it when it zooms, so it never touches an edge. */\nconst FILL = 0.86;\n\ntype Fit = { scale: number; x: number; y: number; hole: string | null };\nconst IDENTITY: Fit = { scale: 1, x: 0, y: 0, hole: null };\n\n/** A rounded rectangle cut out of the frame, as an even-odd clip path in the content's px. */\nfunction cutout(w: number, h: number, r: { x: number; y: number; width: number; height: number }, radius: number) {\n  const k = Math.min(radius, r.width / 2, r.height / 2);\n  const [x0, y0, x1, y1] = [r.x, r.y, r.x + r.width, r.y + r.height];\n  return `path(evenodd, \"M0 0H${w}V${h}H0Z M${x0 + k} ${y0}H${x1 - k}A${k} ${k} 0 0 1 ${x1} ${y0 + k}V${y1 - k}A${k} ${k} 0 0 1 ${x1 - k} ${y1}H${x0 + k}A${k} ${k} 0 0 1 ${x0} ${y1 - k}V${y0 + k}A${k} ${k} 0 0 1 ${x0 + k} ${y0}Z\")`;\n}\n\n/** Zoom that centres the target, never past `max`, and never so far off-centre that an edge of the content shows. */\nfunction fit(content: HTMLElement, el: Element, max: number, padding: number, zoom: boolean): Fit {\n  const w = content.offsetWidth;\n  const h = content.offsetHeight;\n  const box = localRect(el, content);\n  const lit = { x: box.x - padding, y: box.y - padding, width: box.width + padding * 2, height: box.height + padding * 2 };\n  // Concentric with the target's own corner (rule 4, inverted: the hole is the outer shape).\n  const radius = (Number.parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0) + padding;\n  const hole = cutout(w, h, lit, radius);\n  if (!zoom) return { scale: 1, x: 0, y: 0, hole };\n  const scale = Math.max(1, Math.min(max, (w * FILL) / lit.width, (h * FILL) / lit.height));\n  const clamp = (v: number, size: number) => Math.min(0, Math.max(size - size * scale, v));\n  return {\n    scale,\n    x: clamp(w / 2 - scale * (lit.x + lit.width / 2), w),\n    y: clamp(h / 2 - scale * (lit.y + lit.height / 2), h),\n    hole,\n  };\n}\n\n/**\n * Zooms a region of live UI into focus, like a screen recording that pushes in: the view\n * scales and pans on the smooth spring until the target sits centred, the rest dims behind a\n * cutout that hugs the target's corners, and turning it off springs back to the whole view.\n * It follows the target through resizes. Under reduced motion it never zooms; the dim fades\n * in and out.\n */\nexport function ZoomSpotlight({ active, target, scale: max = 1.6, padding = 8, dim = true, className = \"\", children }: ZoomSpotlightProps) {\n  const reduced = useSafeReducedMotion();\n  const content = useRef<HTMLDivElement>(null);\n  const scale = useMotionValue(1);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  // The cutout outlives `active`, so the dim fades out around the same hole it faded in with.\n  const [hole, setHole] = useState<string | null>(null);\n  const key = targetKey(target);\n\n  useLayoutEffect(() => {\n    const el = content.current;\n    if (!el) return;\n    const found = active ? resolveElement(target, el) : null;\n    const settle = (next: Fit) => {\n      if (next.hole) setHole(next.hole);\n      const zoom = reduced ? IDENTITY : next;\n      for (const [value, to] of [\n        [scale, zoom.scale],\n        [x, zoom.x],\n        [y, zoom.y],\n      ] as const) {\n        if (reduced) value.jump(to);\n        else animate(value, to, smooth);\n      }\n    };\n    settle(found ? fit(el, found, max, padding, !reduced) : IDENTITY);\n    // Active with nothing to light: drop the old cutout, so the dim never frames a stale spot.\n    if (active && !found) setHole(null);\n    if (!found) return;\n    // A resize mid-zoom retargets the same spring, so the push-in never jumps.\n    const ro = new ResizeObserver(() => settle(fit(el, found, max, padding, !reduced)));\n    ro.observe(el);\n    ro.observe(found);\n    return () => ro.disconnect();\n    // `target` is keyed by `key`.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [active, key, max, padding, reduced, scale, x, y]);\n\n  const lit = active && dim && hole !== null;\n\n  return (\n    <div className={`relative overflow-hidden ${className}`}>\n      <motion.div ref={content} className=\"relative h-full w-full\" style={{ scale, x, y, transformOrigin: \"0 0\" }}>\n        {children}\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 z-1 bg-scrim\"\n          style={{ clipPath: hole ?? undefined }}\n          initial={false}\n          animate={{ opacity: lit ? 1 : 0 }}\n          transition={reduced ? reducedFade : lit ? { duration: duration.slow, ease: ease.enter } : exit}\n        />\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/zoom-spotlight/zoom-spotlight.tsx"
    }
  ],
  "meta": {
    "details": "Pushes in on a region of live UI the way a screen recording does: the view scales and pans on the smooth spring until the target sits centred, the rest dims behind a cutout that follows its corners, and it springs back when you let go. It never zooms past the edges and follows the target through resizes.",
    "industries": [
      "saas",
      "devtools",
      "ai"
    ],
    "releaseAt": "2026-10-26"
  },
  "docs": "Import ZoomSpotlight from components/eased/zoom-spotlight/zoom-spotlight. Wrap the UI in it, give it a size, and name the region with a ref or a data-demo attribute.",
  "type": "registry:ui"
}