{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ring-cursor",
  "type": "registry:component",
  "title": "Ring Cursor",
  "description": "A ring that follows the cursor with an elastic lag, stretches with speed and, over anything clickable, morphs into its outline like a soft magnet.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Cursors"
  ],
  "docs": "Installed in components/furin/ring-cursor/. Docs, live controls and props table: https://furin.dev/components/ring-cursor/",
  "files": [
    {
      "path": "registry/ring-cursor/RingCursor.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./RingCursor.css\";\n\nexport interface RingCursorProps {\n  /** Diámetro del anillo en reposo, en px. */\n  size?: number;\n  /** Color del anillo y del punto. */\n  color?: string;\n  /** Rigidez del resorte: más alto sigue más pegado al cursor. */\n  stiffness?: number;\n  /** Rebote del resorte: 0.3 se pasa y regresa, 1 llega sin rebotar. */\n  damping?: number;\n  /** Sobre un elemento clicable, el anillo se vuelve su silueta (si no, solo se agranda). */\n  snap?: boolean;\n  /** Cuánto se agranda sobre un clicable cuando no se ajusta a su silueta. */\n  grow?: number;\n  /** Selector de lo que cuenta como clicable. */\n  selector?: string;\n  /** Muestra el punto central que sigue al cursor sin retraso. */\n  dot?: boolean;\n  /** Oculta el cursor del sistema dentro del contenedor. */\n  hideCursor?: boolean;\n  /** Sigue al cursor en toda la ventana en vez de solo en el contenedor. */\n  global?: boolean;\n  /** Se pasea solo, visitando lo clicable, cuando nadie mueve el cursor. */\n  autoplay?: boolean;\n  /** Contenido que queda debajo del cursor (por defecto, unos botones de ejemplo). */\n  children?: ReactNode;\n  /** Textos del contenido de muestra (solo se ven si omites children). */\n  sample?: { title?: string; primary?: string; secondary?: string; link?: string };\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst CLICABLE = \"a[href], button, [role='button'], [role='tab'], [role='link'], input, select, textarea, summary, label[for], [data-cursor]\";\n\n/** Muelle escalar con velocidad: devuelve el nuevo valor y actualiza v por referencia. */\nfunction muelle(x: number, v: number, to: number, k: number, c: number, dt: number): [number, number] {\n  const n = 3;\n  const h = dt / n;\n  for (let i = 0; i < n; i++) {\n    v += (k * (to - x) - c * v) * h;\n    x += v * h;\n  }\n  return [x, v];\n}\n\n/** Anillo con retraso elástico: se estira con la velocidad, se ajusta a la silueta de lo clicable y se aprieta al pulsar. */\nexport function RingCursor({\n  size = 38,\n  color = \"#FFFFFF\",\n  stiffness = 260,\n  damping = 0.62,\n  snap = true,\n  grow = 1.9,\n  selector = CLICABLE,\n  dot = true,\n  hideCursor = true,\n  global = false,\n  autoplay = false,\n  children,\n  sample,\n  className = \"\",\n  style,\n}: RingCursorProps) {\n  const host = useRef<HTMLDivElement>(null);\n  const anillo = useRef<HTMLDivElement>(null);\n  const punto = useRef<HTMLSpanElement>(null);\n  const [activo, setActivo] = useState(true);\n  const live = useRef({ size, stiffness, damping, snap, grow, selector, autoplay });\n  live.current = { size, stiffness, damping, snap, grow, selector, autoplay };\n\n  useEffect(() => {\n    const el = host.current;\n    const ring = anillo.current;\n    if (!el || !ring) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) { setActivo(false); return; }\n    const pt = punto.current;\n    // Estado de los cinco muelles: x, y, ancho, alto y radio.\n    const S = { x: 0, y: 0, w: 38, h: 38, r: 19 };\n    const V = { x: 0, y: 0, w: 0, h: 0, r: 0 };\n    let tx = 0, ty = 0, inside = false, placed = false, lastReal = -1e9, raf = 0, last = 0, visible = true;\n    let hover: Element | null = null, press = false, press1 = 1, pressV = 0, show = 0, t = 0;\n    let autoEl: Element | null = null;\n\n    const origin = () => (global ? { left: 0, top: 0 } : el.getBoundingClientRect());\n    const hitTest = (target: EventTarget | null) => {\n      const n = target instanceof Element ? target.closest(live.current.selector) : null;\n      return n && el.contains(n) ? n : null;\n    };\n\n    const frame = (nowMs: number) => {\n      raf = 0;\n      const dt = Math.min(0.04, Math.max(0.001, (nowMs - last) / 1000));\n      last = nowMs;\n      t += dt;\n      const L = live.current;\n      const o = origin();\n      const auto = L.autoplay && (!inside || nowMs - lastReal > 2500);\n      if (auto) {\n        // Recorre los clicables del contenedor: va, se queda un momento sobre cada uno y sigue.\n        const list = Array.from(el.querySelectorAll(L.selector));\n        const leg = 1.7;\n        const i = Math.floor(t / leg);\n        const ph = (t / leg) % 1;\n        const a = list[i % Math.max(list.length, 1)], b = list[(i + 1) % Math.max(list.length, 1)];\n        if (a && b) {\n          const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();\n          const e = ph < 0.45 ? 0 : Math.min(1, (ph - 0.45) / 0.45);\n          const k = e * e * (3 - 2 * e);\n          tx = ra.left + ra.width / 2 + (rb.left + rb.width / 2 - ra.left - ra.width / 2) * k - o.left;\n          ty = ra.top + ra.height / 2 + (rb.top + rb.height / 2 - ra.top - ra.height / 2) * k - o.top;\n          autoEl = k < 0.5 ? a : b;\n          hover = ph > 0.12 && ph < 0.92 ? autoEl : null;\n        } else {\n          tx = el.clientWidth * (0.5 + 0.3 * Math.sin(t)); ty = el.clientHeight * (0.5 + 0.2 * Math.sin(t * 1.6));\n          hover = null;\n        }\n        if (!placed) { S.x = tx; S.y = ty; placed = true; }\n      } else if (autoEl) { autoEl = null; }\n\n      const vis = inside || auto;\n      show += ((vis ? 1 : 0) - show) * (1 - Math.exp(-dt * 12));\n      pressV += ((press ? 0.86 : 1) - press1) * 420 * dt - pressV * 16 * dt;\n      press1 += pressV * dt;\n\n      // Objetivos: silueta del clicable o círculo en reposo.\n      let gx = tx, gy = ty, gw = L.size, gh = L.size, gr = L.size / 2, snapped = false;\n      if (hover && hover.isConnected) {\n        if (L.snap) {\n          const rc = hover.getBoundingClientRect();\n          const cx = rc.left + rc.width / 2 - o.left, cy = rc.top + rc.height / 2 - o.top;\n          // La silueta se deja atraer un poco hacia el cursor, como un imán suave.\n          gx = cx + (tx - cx) * 0.14;\n          gy = cy + (ty - cy) * 0.18;\n          gw = rc.width + 14;\n          gh = rc.height + 12;\n          const br = parseFloat(getComputedStyle(hover).borderTopLeftRadius) || 0;\n          gr = Math.min(br + 6, gw / 2, gh / 2);\n          snapped = true;\n        } else {\n          gw = gh = L.size * L.grow;\n          gr = gw / 2;\n        }\n      }\n      const k = L.stiffness, c = 2 * Math.sqrt(k) * L.damping;\n      if (!placed) { S.x = tx; S.y = ty; placed = true; }\n      [S.x, V.x] = muelle(S.x, V.x, gx, k, c, dt);\n      [S.y, V.y] = muelle(S.y, V.y, gy, k, c, dt);\n      const kk = snapped ? k * 1.1 : k * 0.8;\n      const cc = 2 * Math.sqrt(kk) * 0.72;\n      [S.w, V.w] = muelle(S.w, V.w, gw, kk, cc, dt);\n      [S.h, V.h] = muelle(S.h, V.h, gh, kk, cc, dt);\n      [S.r, V.r] = muelle(S.r, V.r, gr, kk, cc, dt);\n\n      // Estiramiento por velocidad (solo cuando va libre): se alarga en la dirección del movimiento.\n      const sp = Math.hypot(V.x, V.y);\n      const stretch = snapped ? 0 : Math.min(0.42, sp * 0.00046);\n      const ang = Math.atan2(V.y, V.x);\n      const W = Math.max(S.w, 1), H = Math.max(S.h, 1);\n      ring.style.width = `${W.toFixed(2)}px`;\n      ring.style.height = `${H.toFixed(2)}px`;\n      ring.style.borderRadius = `${Math.max(S.r, 0).toFixed(2)}px`;\n      ring.style.opacity = String(Math.min(1, show).toFixed(3));\n      ring.style.transform = `translate3d(${(S.x - W / 2).toFixed(2)}px, ${(S.y - H / 2).toFixed(2)}px, 0) rotate(${ang}rad) scale(${(1 + stretch).toFixed(3)}, ${(1 / (1 + stretch * 0.7)).toFixed(3)}) rotate(${-ang}rad) scale(${Math.max(press1, 0.5).toFixed(3)})`;\n      ring.classList.toggle(\"is-snap\", snapped);\n      if (pt) {\n        pt.style.opacity = snapped ? \"0\" : String(show.toFixed(3));\n        pt.style.transform = `translate3d(${tx.toFixed(1)}px, ${ty.toFixed(1)}px, 0) scale(${(press ? 1.8 : 1)})`;\n      }\n      const moving = Math.abs(S.x - gx) > 0.05 || Math.abs(S.y - gy) > 0.05 || Math.abs(S.w - gw) > 0.05 || Math.abs(S.h - gh) > 0.05 || Math.abs(V.x) > 0.5 || Math.abs(V.y) > 0.5 || Math.abs(V.w) > 0.2 || Math.abs(V.r) > 0.2 || Math.abs(press1 - (press ? 0.86 : 1)) > 0.002 || Math.abs(pressV) > 0.01 || Math.abs(show - (vis ? 1 : 0)) > 0.004;\n      if ((moving || auto) && visible) raf = requestAnimationFrame(frame);\n    };\n    const wake = () => { if (raf || !visible) return; last = performance.now(); raf = requestAnimationFrame(frame); };\n\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      const o = origin();\n      tx = e.clientX - o.left;\n      ty = e.clientY - o.top;\n      hover = hitTest(e.target);\n      if (!inside && (!placed || show < 0.05)) { S.x = tx; S.y = ty; V.x = V.y = 0; placed = true; }\n      inside = true;\n      lastReal = performance.now();\n      wake();\n    };\n    const onLeave = () => { inside = false; hover = null; wake(); };\n    const onDown = (e: PointerEvent) => { if (e.pointerType === \"touch\") return; press = true; wake(); };\n    const onUp = () => { press = false; wake(); };\n    const evTarget: EventTarget = global ? window : el;\n    evTarget.addEventListener(\"pointermove\", onMove as EventListener);\n    evTarget.addEventListener(\"pointerdown\", onDown as EventListener);\n    evTarget.addEventListener(\"pointerup\", onUp);\n    evTarget.addEventListener(\"pointercancel\", onUp);\n    el.addEventListener(\"pointerleave\", onLeave);\n    if (global) document.documentElement.addEventListener(\"pointerleave\", onLeave);\n    const io = new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) wake(); else if (raf) { cancelAnimationFrame(raf); raf = 0; } });\n    io.observe(el);\n    wake();\n    return () => {\n      cancelAnimationFrame(raf);\n      evTarget.removeEventListener(\"pointermove\", onMove as EventListener);\n      evTarget.removeEventListener(\"pointerdown\", onDown as EventListener);\n      evTarget.removeEventListener(\"pointerup\", onUp);\n      evTarget.removeEventListener(\"pointercancel\", onUp);\n      el.removeEventListener(\"pointerleave\", onLeave);\n      if (global) document.documentElement.removeEventListener(\"pointerleave\", onLeave);\n      io.disconnect();\n    };\n  }, [global]);\n\n  const vars = { \"--furin-ring-color\": color } as CSSProperties;\n  return (\n    <div ref={host} className={`furin-ring-cursor${hideCursor && activo ? \" furin-ring-cursor--sin-cursor\" : \"\"} ${className}`} style={{ ...vars, ...style }}>\n      <div className=\"furin-ring-cursor__contenido\">{children ?? <Demo {...sample} />}</div>\n      {activo ? (\n        <div className={`furin-ring-cursor__capa${global ? \" furin-ring-cursor__capa--global\" : \"\"}`} aria-hidden=\"true\">\n          <div ref={anillo} className=\"furin-ring-cursor__anillo\" />\n          {dot ? <span ref={punto} className=\"furin-ring-cursor__punto\" /> : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\n/** Contenido de ejemplo: lo que se ilumina es todo lo clicable. */\nfunction Demo({ title = \"Everything clickable\\nis recognized.\", primary = \"Get started\", secondary = \"Documentation\", link = \"See examples\" }: { title?: string; primary?: string; secondary?: string; link?: string }) {\n  return (\n    <div className=\"furin-ring-cursor__demo\">\n      <p className=\"furin-ring-cursor__titulo\">{title.split(\"\\n\").flatMap((l, i) => (i ? [<br key={i} />, l] : [l]))}</p>\n      <div className=\"furin-ring-cursor__fila\">\n        <button type=\"button\" className=\"furin-ring-cursor__btn furin-ring-cursor__btn--lleno\">{primary}</button>\n        <button type=\"button\" className=\"furin-ring-cursor__btn\">{secondary}</button>\n        <a href=\"#anillo\" className=\"furin-ring-cursor__enlace\" onClick={(e) => e.preventDefault()}>{link}</a>\n      </div>\n    </div>\n  );\n}\n\nexport default RingCursor;\n",
      "type": "registry:component",
      "target": "components/furin/ring-cursor/RingCursor.tsx"
    },
    {
      "path": "registry/ring-cursor/RingCursor.css",
      "content": ".furin-ring-cursor {\n  --furin-ring-color: #fff;\n  position: relative;\n  width: 100%;\n  height: 100%;\n  min-height: 220px;\n  overflow: hidden;\n  touch-action: pan-y;\n  font-family: Geist, system-ui, -apple-system, sans-serif;\n  color: #fff;\n}\n@media (hover: hover) and (pointer: fine) {\n  .furin-ring-cursor--sin-cursor, .furin-ring-cursor--sin-cursor * { cursor: none !important; }\n}\n.furin-ring-cursor__contenido { position: relative; width: 100%; height: 100%; display: grid; place-items: center; }\n.furin-ring-cursor__capa { position: absolute; inset: 0; z-index: 3; pointer-events: none; overflow: hidden; }\n.furin-ring-cursor__capa--global { position: fixed; z-index: 2147483000; overflow: visible; }\n.furin-ring-cursor__anillo {\n  position: absolute;\n  left: 0; top: 0;\n  box-sizing: border-box;\n  border: 1.5px solid color-mix(in srgb, var(--furin-ring-color) 80%, transparent);\n  background: transparent;\n  opacity: 0;\n  will-change: transform, width, height;\n  transition: background-color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;\n}\n.furin-ring-cursor__anillo.is-snap {\n  background: color-mix(in srgb, var(--furin-ring-color) 11%, transparent);\n  border-color: color-mix(in srgb, var(--furin-ring-color) 55%, transparent);\n  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 10px 30px -10px color-mix(in srgb, var(--furin-ring-color) 35%, transparent);\n}\n.furin-ring-cursor__punto {\n  position: absolute;\n  left: -2.5px; top: -2.5px;\n  width: 5px; height: 5px;\n  border-radius: 50%;\n  background: var(--furin-ring-color);\n  opacity: 0;\n  box-shadow: 0 0 8px color-mix(in srgb, var(--furin-ring-color) 60%, transparent);\n  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);\n}\n\n/* Contenido de ejemplo */\n.furin-ring-cursor__demo { display: grid; justify-items: center; gap: 22px; padding: 0 20px; text-align: center; }\n.furin-ring-cursor__titulo { margin: 0; font-size: clamp(24px, 5.6vw, 32px); font-weight: 600; letter-spacing: -0.03em; line-height: 1.1; color: #f5f6f8; }\n.furin-ring-cursor__fila { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: center; }\n.furin-ring-cursor__btn {\n  appearance: none;\n  font: 500 14px/1 Geist, system-ui, sans-serif;\n  color: #e8eaee;\n  padding: 12px 18px;\n  border-radius: 12px;\n  border: 1px solid rgba(255, 255, 255, 0.14);\n  background: rgba(255, 255, 255, 0.04);\n}\n.furin-ring-cursor__btn--lleno { background: #f5f6f8; color: #0b0c0f; border-color: transparent; }\n.furin-ring-cursor__enlace { font-size: 14px; color: #aab1c0; text-decoration: none; padding: 8px 10px; border-radius: 8px; }\n.furin-ring-cursor__btn:focus-visible, .furin-ring-cursor__enlace:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.7); outline-offset: 3px; }\n",
      "type": "registry:file",
      "target": "components/furin/ring-cursor/RingCursor.css"
    }
  ]
}
