{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orbit-text",
  "type": "registry:component",
  "title": "Orbit Text",
  "description": "Text on a circle that turns slowly and speeds up, with inertia, when the cursor gets close.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/orbit-text/. Docs, live controls and props table: https://furin.dev/components/orbit-text/",
  "files": [
    {
      "path": "registry/orbit-text/OrbitText.tsx",
      "content": "\"use client\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./OrbitText.css\";\n\nexport interface OrbitTextProps {\n  /** El texto del anillo exterior; se repite hasta cerrar el círculo. */\n  children?: string;\n  /** Texto del anillo interior, que gira al revés (déjalo vacío para ocultarlo). */\n  innerText?: string;\n  /** Diámetro del conjunto, en px. */\n  size?: number;\n  /** Grados por segundo en reposo. */\n  speed?: number;\n  /** Cuántas veces más rápido gira con el cursor encima. */\n  boost?: number;\n  /** Símbolo que separa las repeticiones del texto. */\n  separator?: string;\n  /** Color del texto. */\n  color?: string;\n  /** Color del separador y del centro. */\n  accent?: string;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst C0 = 200; // centro del lienzo (viewBox 400×400)\ninterface Anillo { r: number; fs: number; texto: string; ls: number }\ninterface Ajuste { str: string; ls: number }\n\nconst circ = (r: number) => 2 * Math.PI * r;\n\n/** Estimación inicial (antes de medir la fuente): sirve para el primer pintado y para SSR. */\nfunction estimar(texto: string, sep: string, r: number, fs: number): Ajuste {\n  const unidad = `${texto} ${sep} `;\n  const w = unidad.length * fs * 0.66;\n  const n = Math.max(1, Math.round(circ(r) / w));\n  return { str: unidad.repeat(n), ls: 0 };\n}\n\n/** Texto en círculo que gira despacio y se acelera, con inercia, cuando el cursor se acerca. */\nexport function OrbitText({\n  children = \"Design in motion\",\n  innerText = \"furin · animated · components\",\n  size = 340,\n  speed = 12,\n  boost = 8,\n  separator = \"•\",\n  color = \"#F4F5F7\",\n  accent = \"#7CF5C6\",\n  className = \"\",\n  style,\n}: OrbitTextProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const medidor = useRef<SVGTextElement>(null);\n  const gExt = useRef<SVGGElement>(null);\n  const gInt = useRef<SVGGElement>(null);\n  const centro = useRef<SVGCircleElement>(null);\n  const halo = useRef<SVGCircleElement>(null);\n  const cfg = useRef({ speed, boost });\n  cfg.current = { speed, boost };\n\n  const sep = separator || \"•\";\n  const ext = children.toUpperCase();\n  const int = innerText.toUpperCase();\n  const A: Anillo = { r: 158, fs: 25, texto: ext, ls: 0 };\n  const B: Anillo = { r: 100, fs: 15, texto: int, ls: 0 };\n  const [aj, setAj] = useState<{ a: Ajuste; b: Ajuste }>(() => ({ a: estimar(ext, sep, A.r, A.fs), b: estimar(int, sep, B.r, B.fs) }));\n\n  // Mide el texto real con la fuente ya cargada y reparte el espacio sobrante entre las letras para que el círculo cierre exacto.\n  useEffect(() => {\n    const m = medidor.current;\n    if (!m) return;\n    const ajustar = () => {\n      const hacer = (an: Anillo): Ajuste => {\n        if (!an.texto.trim()) return { str: \"\", ls: 0 };\n        m.style.fontSize = `${an.fs}px`;\n        m.textContent = `${an.texto} ${sep} `;\n        const w = m.getComputedTextLength();\n        if (!w) return estimar(an.texto, sep, an.r, an.fs);\n        const unidad = `${an.texto} ${sep} `;\n        const n = Math.max(1, Math.round(circ(an.r) / w));\n        const total = unidad.length * n;\n        return { str: unidad.repeat(n), ls: (circ(an.r) - w * n) / total };\n      };\n      setAj({ a: hacer(A), b: hacer(B) });\n    };\n    ajustar();\n    document.fonts?.ready.then(ajustar);\n    document.fonts?.addEventListener?.(\"loadingdone\", ajustar);\n    return () => document.fonts?.removeEventListener?.(\"loadingdone\", ajustar);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [ext, int, sep]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const E = { a1: -20, a2: 40, v: 1, prox: 0, ultimo: 0 };\n    let visible = true, raf = 0, muerto = false;\n    const pintar = () => {\n      gExt.current?.setAttribute(\"transform\", `rotate(${E.a1.toFixed(2)} ${C0} ${C0})`);\n      gInt.current?.setAttribute(\"transform\", `rotate(${E.a2.toFixed(2)} ${C0} ${C0})`);\n      // El centro late más fuerte cuanto más rápido gira.\n      const k = Math.min(1, (E.v - 1) / 5);\n      centro.current?.setAttribute(\"r\", (4 + k * 3).toFixed(2));\n      halo.current?.setAttribute(\"r\", (13 + k * 15).toFixed(2));\n      halo.current?.setAttribute(\"opacity\", (0.25 + k * 0.6).toFixed(3));\n    };\n    pintar();\n    if (reducido) return;\n    const paso = (now: number) => {\n      raf = 0;\n      if (muerto || !visible) return;\n      const dt = Math.min(0.05, (now - (E.ultimo || now)) / 1000 || 0.016);\n      E.ultimo = now;\n      const { speed: s, boost: b } = cfg.current;\n      const objetivo = 1 + (b - 1) * E.prox;\n      // Entra rápido y se suelta despacio: tiene inercia, como un volante.\n      const tau = objetivo > E.v ? 0.45 : 1.3;\n      E.v += (objetivo - E.v) * (1 - Math.exp(-dt / tau));\n      E.a1 += s * E.v * dt;\n      E.a2 -= s * 0.72 * E.v * dt;\n      pintar();\n      raf = requestAnimationFrame(paso);\n    };\n    const wake = () => { if (!raf && visible && !muerto) { E.ultimo = 0; raf = requestAnimationFrame(paso); } };\n    const onMove = (e: PointerEvent) => {\n      const r = root.getBoundingClientRect();\n      const d = Math.hypot(e.clientX - (r.left + r.width / 2), e.clientY - (r.top + r.height / 2));\n      const rad = r.width / 2;\n      // Dentro del círculo: aceleración completa; hasta 0.5 radios afuera se desvanece.\n      E.prox = Math.max(0, Math.min(1, 1 - (d - rad) / (rad * 0.5)));\n    };\n    const onFuera = () => { E.prox = 0; };\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n    window.addEventListener(\"pointerdown\", onMove, { passive: true });\n    const onUp = (e: PointerEvent) => { if (e.pointerType !== \"mouse\") onFuera(); };\n    window.addEventListener(\"pointerup\", onUp, { passive: true });\n    document.documentElement.addEventListener(\"pointerleave\", onFuera);\n    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) wake(); }, { rootMargin: \"60px\" });\n    io.observe(root);\n    wake();\n    return () => {\n      muerto = true; cancelAnimationFrame(raf); io.disconnect();\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerdown\", onMove);\n      window.removeEventListener(\"pointerup\", onUp);\n      document.documentElement.removeEventListener(\"pointerleave\", onFuera);\n    };\n  }, []);\n\n  const trazo = (r: number) => `M ${C0} ${C0 - r} A ${r} ${r} 0 1 1 ${C0} ${C0 + r} A ${r} ${r} 0 1 1 ${C0} ${C0 - r}`;\n  /** Reparte el texto en tramos para pintar el separador con el color de acento. */\n  const tramos = (str: string) => str.split(sep).map((t, i, arr) => (\n    <tspan key={i}>{t}{i < arr.length - 1 ? <tspan className=\"furin-orbit-text__sep\">{sep}</tspan> : null}</tspan>\n  ));\n  const vars = { \"--furin-ot-color\": color, \"--furin-ot-accent\": accent, width: size, height: size } as CSSProperties;\n\n  return (\n    <div ref={rootRef} className={`furin-orbit-text ${className}`} style={{ ...vars, ...style }}>\n      <svg viewBox=\"0 0 400 400\" role=\"img\" aria-label={`${children}${innerText ? `, ${innerText}` : \"\"}`}>\n        <defs>\n          <path id={`${uid}-a`} d={trazo(A.r)} />\n          <path id={`${uid}-b`} d={trazo(B.r)} />\n          <radialGradient id={`${uid}-g`}>\n            <stop offset=\"0\" stopColor={accent} stopOpacity=\"0.55\" />\n            <stop offset=\"1\" stopColor={accent} stopOpacity=\"0\" />\n          </radialGradient>\n        </defs>\n        <circle className=\"furin-orbit-text__aro\" cx={C0} cy={C0} r={A.r - 22} />\n        <circle className=\"furin-orbit-text__aro furin-orbit-text__aro--fino\" cx={C0} cy={C0} r={B.r - 18} />\n        <g ref={gExt}>\n          <text className=\"furin-orbit-text__texto\" fontSize={A.fs} style={{ letterSpacing: `${aj.a.ls.toFixed(3)}px` }}>\n            <textPath href={`#${uid}-a`}>{tramos(aj.a.str)}</textPath>\n          </text>\n        </g>\n        <g ref={gInt}>\n          <text className=\"furin-orbit-text__texto furin-orbit-text__texto--interior\" fontSize={B.fs} style={{ letterSpacing: `${aj.b.ls.toFixed(3)}px` }}>\n            <textPath href={`#${uid}-b`}>{tramos(aj.b.str)}</textPath>\n          </text>\n        </g>\n        <circle ref={halo} cx={C0} cy={C0} r=\"13\" fill={`url(#${uid}-g)`} opacity=\"0.25\" />\n        <circle ref={centro} className=\"furin-orbit-text__centro\" cx={C0} cy={C0} r=\"4\" />\n        <text ref={medidor} className=\"furin-orbit-text__texto\" x=\"0\" y=\"0\" visibility=\"hidden\" aria-hidden=\"true\" />\n      </svg>\n    </div>\n  );\n}\n\nexport default OrbitText;\n",
      "type": "registry:component",
      "target": "components/furin/orbit-text/OrbitText.tsx"
    },
    {
      "path": "registry/orbit-text/OrbitText.css",
      "content": ".furin-orbit-text {\n  display: block;\n  max-width: 100%;\n  aspect-ratio: 1;\n  font-family: \"Geist\", \"Inter\", \"SF Pro Display\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif;\n  -webkit-font-smoothing: antialiased;\n}\n.furin-orbit-text svg { display: block; width: 100%; height: 100%; overflow: visible; }\n.furin-orbit-text__texto {\n  fill: var(--furin-ot-color);\n  font-weight: 650;\n  white-space: pre;\n  text-rendering: geometricPrecision;\n}\n.furin-orbit-text__texto--interior { fill: color-mix(in srgb, var(--furin-ot-color) 52%, transparent); font-weight: 560; }\n.furin-orbit-text__sep { fill: var(--furin-ot-accent); }\n.furin-orbit-text__aro { fill: none; stroke: color-mix(in srgb, var(--furin-ot-color) 14%, transparent); stroke-width: 1; }\n.furin-orbit-text__aro--fino { stroke: color-mix(in srgb, var(--furin-ot-color) 8%, transparent); }\n.furin-orbit-text__centro { fill: var(--furin-ot-accent); }\n",
      "type": "registry:file",
      "target": "components/furin/orbit-text/OrbitText.css"
    }
  ]
}
