{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cursor-snap-target","type":"registry:component","title":"Snapping Cursor","description":"A cursor dot that latches onto interactive elements and takes their exact shape and corner radius, leans toward the pointer while locked, follows keyboard focus and lights up on touch. No dependencies.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/cursor-snap-target.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, type ReactNode } from \"react\";\n\nexport type CursorSnapTargetProps = {\n  /** Anything. Descendants carrying data-cursor-snap become snap targets. */\n  children: ReactNode;\n  /** Diameter of the idle dot in px. */\n  idleSize?: number;\n  /** Gap in px between a target's edge and the ring that wraps it. */\n  padding?: number;\n  /** Keep the dot visible in open space. Off, it only appears when snapped. */\n  showIdleDot?: boolean;\n  /** Magnetic pull: how far (0 to 0.5) the ring leans toward the pointer while locked. 0 pins it. */\n  magnet?: number;\n  /** CSS selector of a descendant to park the ring around while no pointer is in the wrapper (a resting state). Default: none. */\n  restTarget?: string;\n  /** Ring colour: #hex, rgb(), hsl() or oklch(). */\n  color?: string;\n  /** Goes on the wrapper element. */\n  className?: string;\n};\n\nconst SELECTOR = \"[data-cursor-snap]:not(:disabled):not([aria-disabled='true'])\";\nconst COLOR_OK = /^(#[0-9a-f]{3,8}|(rgb|hsl|oklch)a?\\([0-9a-z\\s.,%/+-]{3,48}\\))$/i;\nconst DEFAULT_COLOR = \"#3b82f6\";\nconst MAX_LEAN = 12;\n\ntype Spring = { v: number; t: number; vel: number; eps: number };\nconst spring = (v: number, eps: number): Spring => ({ v, t: v, vel: 0, eps });\nconst settled = (s: Spring) => Math.abs(s.v - s.t) < s.eps && Math.abs(s.vel) < s.eps * 2;\nconst advance = (s: Spring, k: number, c: number, m: number, dt: number) => {\n  const a = (-k * (s.v - s.t) - c * s.vel) / m;\n  s.vel += a * dt;\n  s.v += s.vel * dt;\n};\n\n/** First corner radius of an element, in px, clamped to a pill. */\nfunction readRadius(el: HTMLElement, w: number, h: number) {\n  const first = (getComputedStyle(el).borderTopLeftRadius || \"0\").split(\" \")[0] ?? \"0\";\n  const n = parseFloat(first);\n  const px = Number.isFinite(n) ? (first.endsWith(\"%\") ? (n / 100) * Math.min(w, h) : n) : 0;\n  return Math.max(0, Math.min(px, Math.min(w, h) / 2));\n}\n\nexport function CursorSnapTarget({\n  children,\n  idleSize = 10,\n  padding = 6,\n  showIdleDot = true,\n  magnet = 0.16,\n  restTarget,\n  color = DEFAULT_COLOR,\n  className,\n}: CursorSnapTargetProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<HTMLDivElement>(null);\n  const glowRef = useRef<HTMLSpanElement>(null);\n  const fillRef = useRef<HTMLSpanElement>(null);\n  const spotRef = useRef<HTMLSpanElement>(null);\n  const cfg = useRef({ idleSize, padding, showIdleDot, magnet, restTarget });\n  const wakeRef = useRef<(() => void) | null>(null);\n  const tint = COLOR_OK.test(color.trim()) ? color.trim() : DEFAULT_COLOR;\n\n  useEffect(() => {\n    cfg.current = { idleSize, padding, showIdleDot, magnet, restTarget };\n    wakeRef.current?.();\n  }, [idleSize, padding, showIdleDot, magnet, restTarget]);\n\n  useEffect(() => {\n    const box = boxRef.current;\n    const ring = ringRef.current;\n    const glow = glowRef.current;\n    const fill = fillRef.current;\n    const spot = spotRef.current;\n    if (!box || !ring || !glow || !fill || !spot) return;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduce = mq.matches;\n    const onMq = () => {\n      reduce = mq.matches;\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    const sp = {\n      x: spring(0, 0.04),\n      y: spring(0, 0.04),\n      w: spring(0, 0.04),\n      h: spring(0, 0.04),\n      r: spring(0, 0.04),\n      a: spring(0, 0.002),\n      f: spring(0.9, 0.002),\n      g: spring(0, 0.002),\n      s: spring(1, 0.0006),\n      l: spring(0, 0.002),\n    };\n    const st = {\n      px: 0,\n      py: 0,\n      inside: false,\n      touch: false,\n      down: false,\n      hover: null as HTMLElement | null,\n      focus: null as HTMLElement | null,\n      radiusKey: \"\",\n      radiusEl: null as HTMLElement | null,\n      radius: 0,\n    };\n    let raf = 0;\n    let last = 0;\n    let lastKey = \"\";\n    let touchTimer = 0;\n\n    const aim = () => {\n      const c = cfg.current;\n      const rect = box.getBoundingClientRect();\n      const k = rect.width > 0 ? box.offsetWidth / rect.width : 1;\n      let el = st.hover ?? st.focus;\n      if (!el && !st.inside && c.restTarget) {\n        try {\n          el = box.querySelector<HTMLElement>(c.restTarget);\n        } catch {\n          el = null;\n        }\n      }\n      if (el && !el.isConnected) {\n        st.hover = null;\n        st.focus = null;\n        el = null;\n      }\n      let x: number;\n      let y: number;\n      let w: number;\n      let h: number;\n      let rad: number;\n      let alpha: number;\n      let fillT: number;\n      let glowT: number;\n      let litT: number;\n      const mouse = !st.touch;\n      if (el) {\n        const r = el.getBoundingClientRect();\n        const bw = r.width * k;\n        const bh = r.height * k;\n        w = bw + c.padding * 2;\n        h = bh + c.padding * 2;\n        x = (r.left - rect.left) * k - c.padding;\n        y = (r.top - rect.top) * k - c.padding;\n        const key = Math.round(bw) + \"x\" + Math.round(bh);\n        if (el !== st.radiusEl || key !== st.radiusKey) {\n          st.radiusEl = el;\n          st.radiusKey = key;\n          st.radius = readRadius(el, bw, bh);\n        }\n        rad = Math.min(st.radius + c.padding, w / 2, h / 2);\n        if (st.hover === el && st.inside && c.magnet > 0 && !reduce) {\n          let dx = (st.px - (x + w / 2)) * c.magnet;\n          let dy = (st.py - (y + h / 2)) * c.magnet;\n          const len = Math.hypot(dx, dy);\n          if (len > MAX_LEAN) {\n            dx *= MAX_LEAN / len;\n            dy *= MAX_LEAN / len;\n          }\n          x += dx;\n          y += dy;\n        }\n        alpha = 1;\n        fillT = st.down ? 0.26 : 0.13;\n        glowT = 0.3;\n        litT = st.hover === el && st.inside ? 1 : 0.5;\n      } else {\n        w = c.idleSize;\n        h = c.idleSize;\n        x = st.px - w / 2;\n        y = st.py - h / 2;\n        rad = w / 2;\n        alpha = st.inside && mouse && c.showIdleDot ? 1 : 0;\n        fillT = 0.9;\n        glowT = 0.22;\n        litT = 0;\n      }\n      sp.x.t = x;\n      sp.y.t = y;\n      sp.w.t = w;\n      sp.h.t = h;\n      sp.r.t = rad;\n      sp.a.t = alpha;\n      sp.f.t = fillT;\n      sp.g.t = glowT;\n      sp.l.t = litT;\n      sp.s.t = st.down ? (el ? 0.965 : 0.72) : 1;\n      // Entering from nothing: appear in place rather than flying in.\n      if (alpha > 0 && sp.a.v < 0.02) {\n        sp.x.v = x;\n        sp.y.v = y;\n        sp.w.v = w;\n        sp.h.v = h;\n        sp.r.v = rad;\n        sp.x.vel = sp.y.vel = sp.w.vel = sp.h.vel = sp.r.vel = 0;\n      }\n      return x + \",\" + y + \",\" + w + \",\" + h + \",\" + st.px + \",\" + st.py + \",\" + alpha + \",\" + st.down + \",\" + (el ? 1 : 0);\n    };\n\n    const paint = () => {\n      ring.style.width = sp.w.v + \"px\";\n      ring.style.height = sp.h.v + \"px\";\n      ring.style.borderRadius = Math.max(0, sp.r.v) + \"px\";\n      ring.style.transform = \"translate3d(\" + sp.x.v + \"px,\" + sp.y.v + \"px,0) scale(\" + sp.s.v + \")\";\n      ring.style.opacity = String(Math.max(0, Math.min(1, sp.a.v)));\n      fill.style.opacity = String(Math.max(0, Math.min(1, sp.f.v)));\n      glow.style.opacity = String(Math.max(0, Math.min(1, sp.g.v)));\n      spot.style.opacity = String(Math.max(0, Math.min(1, sp.l.v)) * 0.34);\n      const mask = \"radial-gradient(circle 90px at \" + (st.px - sp.x.v) + \"px \" + (st.py - sp.y.v) + \"px, #000, transparent)\";\n      spot.style.maskImage = mask;\n      spot.style.webkitMaskImage = mask;\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      const key = aim();\n      const all = Object.values(sp);\n      if (reduce) {\n        for (const s of all) {\n          s.v = s.t;\n          s.vel = 0;\n        }\n      } else {\n        const n = Math.max(1, Math.ceil(dt * 240));\n        const h = dt / n;\n        for (let i = 0; i < n; i++) {\n          advance(sp.x, 480, 36, 0.6, h);\n          advance(sp.y, 480, 36, 0.6, h);\n          advance(sp.w, 480, 36, 0.6, h);\n          advance(sp.h, 480, 36, 0.6, h);\n          advance(sp.r, 480, 36, 0.6, h);\n          advance(sp.s, 700, 34, 0.5, h);\n          advance(sp.a, 260, 30, 1, h);\n          advance(sp.f, 260, 30, 1, h);\n          advance(sp.g, 160, 24, 1, h);\n          advance(sp.l, 160, 24, 1, h);\n        }\n      }\n      paint();\n      if (key === lastKey && all.every(settled)) {\n        last = 0;\n        return;\n      }\n      lastKey = key;\n      raf = requestAnimationFrame(frame);\n    };\n\n    const wake = () => {\n      if (!raf) {\n        last = 0;\n        raf = requestAnimationFrame(frame);\n      }\n    };\n    wakeRef.current = wake;\n\n    const local = (e: PointerEvent) => {\n      const r = box.getBoundingClientRect();\n      const k = r.width > 0 ? box.offsetWidth / r.width : 1;\n      st.px = (e.clientX - r.left) * k;\n      st.py = (e.clientY - r.top) * k;\n    };\n    const pick = (t: EventTarget | null) => {\n      if (!(t instanceof Element)) return null;\n      const el = t.closest<HTMLElement>(SELECTOR);\n      return el && box.contains(el) ? el : null;\n    };\n\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      st.touch = false;\n      st.inside = true;\n      local(e);\n      st.hover = pick(e.target);\n      wake();\n    };\n    const onDown = (e: PointerEvent) => {\n      window.clearTimeout(touchTimer);\n      st.touch = e.pointerType === \"touch\";\n      st.inside = true;\n      st.down = true;\n      local(e);\n      st.hover = pick(e.target);\n      wake();\n    };\n    const onUp = (e: PointerEvent) => {\n      if (!st.down) return;\n      st.down = false;\n      if (e.pointerType === \"touch\") {\n        // Let the highlight linger a beat so a quick tap still reads.\n        touchTimer = window.setTimeout(() => {\n          st.hover = null;\n          st.inside = false;\n          wake();\n        }, 420);\n      }\n      wake();\n    };\n    const onLeave = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      st.inside = false;\n      st.hover = null;\n      wake();\n    };\n    const onFocusIn = (e: FocusEvent) => {\n      const el = pick(e.target);\n      if (!el) return;\n      let visible = true;\n      try {\n        visible = el.matches(\":focus-visible\");\n      } catch {\n        visible = true;\n      }\n      if (visible) {\n        st.focus = el;\n        wake();\n      }\n    };\n    const onFocusOut = (e: FocusEvent) => {\n      if (e.target === st.focus) {\n        st.focus = null;\n        wake();\n      }\n    };\n    const onShift = () => {\n      if (st.hover || st.focus) wake();\n    };\n\n    box.addEventListener(\"pointermove\", onMove);\n    box.addEventListener(\"pointerdown\", onDown);\n    box.addEventListener(\"pointerleave\", onLeave);\n    box.addEventListener(\"focusin\", onFocusIn);\n    box.addEventListener(\"focusout\", onFocusOut);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onUp);\n    window.addEventListener(\"scroll\", onShift, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onShift);\n    wake();\n\n    return () => {\n      wakeRef.current = null;\n      cancelAnimationFrame(raf);\n      window.clearTimeout(touchTimer);\n      mq.removeEventListener(\"change\", onMq);\n      box.removeEventListener(\"pointermove\", onMove);\n      box.removeEventListener(\"pointerdown\", onDown);\n      box.removeEventListener(\"pointerleave\", onLeave);\n      box.removeEventListener(\"focusin\", onFocusIn);\n      box.removeEventListener(\"focusout\", onFocusOut);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onUp);\n      window.removeEventListener(\"scroll\", onShift, true);\n      window.removeEventListener(\"resize\", onShift);\n    };\n  }, []);\n\n  return (\n    <div ref={boxRef} className={\"relative cursor-none\" + (className ? \" \" + className : \"\")}>\n      {children}\n      <div\n        ref={ringRef}\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          width: 0,\n          height: 0,\n          opacity: 0,\n          pointerEvents: \"none\",\n          boxSizing: \"border-box\",\n          willChange: \"transform, width, height\",\n          zIndex: 50,\n        }}\n      >\n        <span\n          ref={glowRef}\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            borderRadius: \"inherit\",\n            background: tint,\n            filter: \"blur(12px)\",\n            opacity: 0,\n          }}\n        />\n        <span\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            overflow: \"hidden\",\n            borderRadius: \"inherit\",\n            border: \"1px solid \" + tint,\n            boxSizing: \"border-box\",\n          }}\n        >\n          <span ref={fillRef} style={{ position: \"absolute\", inset: 0, background: tint, opacity: 0.9 }} />\n          <span ref={spotRef} style={{ position: \"absolute\", inset: 0, background: tint, opacity: 0 }} />\n        </span>\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/cursor-snap-target.tsx"}],"meta":{"tier":"free","tags":["cursor","magnetic","snap","spring","pointer","focus","touch"],"href":"https://viberdy.dev/library/cursor-snap-target","aiPrompt":"Create a single-file React + Tailwind component named CursorSnapTarget (a \"use client\" file, React only, no animation library) that wraps children and draws a ring cursor that snaps onto interactive descendants and takes their shape.\n\nProps: children; idleSize 10 (idle dot diameter, px); padding 6 (gap between target edge and ring, px); showIdleDot true (when false the dot is hidden in open space and only appears when snapped); magnet 0.16 (0 to 0.5, how far the ring leans toward the pointer while locked); color \"#3b82f6\" (validate against a regex for #hex / rgb / hsl / oklch, fall back to the default; used only in inline styles); restTarget (optional CSS selector of a descendant; while no pointer is inside the wrapper the ring parks around it, default none); className (appended to the wrapper).\n\nStructure: a wrapper div \"relative cursor-none\" holding children and one absolutely positioned ring (aria-hidden, pointer-events none, z-index 50, box-sizing border-box, starts opacity 0 and 0x0). The ring holds a blurred glow span (blur 12px, same colour, radius inherit), and a clip span (overflow hidden, radius inherit, 1px solid border in the colour) containing a fill span (solid colour) and a spot span (solid colour masked by radial-gradient(circle 90px at the pointer, #000, transparent), opacity up to 0.34). React renders only static styles; an engine writes width, height, borderRadius, transform translate3d + scale and opacities straight to the DOM, so pointer movement never re-renders.\n\nTargets: any descendant with data-cursor-snap that is not :disabled and not aria-disabled=\"true\", found with event.target.closest(). The ring takes the target's getBoundingClientRect (converted to layout px via offsetWidth / rect.width), plus padding on each side; radius = the target's computed border-top-left-radius (percent converted using its box, clamped to half the short side) + padding. Re-measure the locked target every frame so scroll, resize and animating targets stay wrapped. Window scroll (capture) and resize wake the loop.\n\nStates: idle dot = idleSize circle, fill opacity 0.9, glow 0.22, follows the pointer centred on it, only for mouse/pen inside the wrapper. Snapped = fill 0.13 (0.26 while pressed), glow 0.3, spot 1 when hovered. While snapped free tracking is suspended; the ring offset leans toward the pointer by (pointer - ring centre) * magnet, length capped at 12px. Pressed: ring scale 0.965 when snapped, 0.72 for the idle dot. When the ring appears from invisible, jump to its target instead of flying in.\n\nMotion: one requestAnimationFrame loop inside a mount effect, semi-implicit Euler springs integrated in 1/240 s sub-steps with dt clamped to 50 ms. x, y, width, height, radius: stiffness 480, damping 36, mass 0.6. Scale: 700/34/0.5. Alpha and fill: 260/30/1. Glow and spot: 160/24/1. The loop stops when every spring is settled and the target signature is unchanged, and wakes on pointer, focus, scroll, resize and prop changes. Props the loop reads go through a ref synced in an effect.\n\nInput: pointermove (mouse/pen) sets hover; pointerdown sets pressed and hover (touch included); pointerup/cancel on window release; pointerleave clears. Touch: no idle dot, a press shows the ring on the target and it lingers 420 ms after release. Rest: with restTarget set and no hover, focus or pointer inside, the ring wraps that element (no lean). Keyboard: focusin on a target that matches :focus-visible snaps the ring to it (no pointer needed), focusout releases; hover wins over focus.\n\nAccessibility: ring is aria-hidden; targets stay real buttons/links; consumers keep their own focus style. prefers-reduced-motion (read with matchMedia in an effect, listen for changes): no springs, no lean, no scale, values jump to targets. Without JS nothing renders but the children.","license":"https://viberdy.dev/license"}}