{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "weight-wave",
  "type": "registry:component",
  "title": "Weight Wave",
  "description": "The weight of a variable font ripples with the cursor's proximity, letter by letter.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/weight-wave/. Docs, live controls and props table: https://furin.dev/components/weight-wave/",
  "files": [
    {
      "path": "registry/weight-wave/WeightWave.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, RefObject } from \"react\";\nimport \"./WeightWave.css\";\n\nexport interface WeightWaveProps {\n  /** El texto (acepta saltos de línea). */\n  children?: string;\n  /** Tamaño de la letra, en px. */\n  size?: number;\n  /** Familia de Google Fonts con eje de peso (wght). Si no carga, se usa una de respaldo. */\n  family?: string;\n  /** Peso de las letras lejos del cursor (100 a 1000). */\n  minWeight?: number;\n  /** Peso de las letras justo bajo el cursor (100 a 1000). */\n  maxWeight?: number;\n  /** Radio de influencia del cursor, en px. */\n  radius?: number;\n  /** Cuando no hay cursor cerca, una ola recorre el texto sola. */\n  idle?: boolean;\n  /** Color del texto. */\n  color?: string;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\n/** Carga la fuente de Google Fonts una sola vez por familia y rango de peso. */\nfunction cargarFuente(family: string, min: number, max: number) {\n  if (typeof document === \"undefined\" || !family) return;\n  const id = `furin-ww-${family.replace(/\\W+/g, \"-\")}-${min}-${max}`;\n  if (document.getElementById(id)) return;\n  const link = document.createElement(\"link\");\n  link.id = id;\n  link.rel = \"stylesheet\";\n  link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, \"+\")}:wght@${min}..${max}&display=swap`;\n  document.head.appendChild(link);\n}\n\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\n\n/** Si el texto no cabe en su contenedor, encoge la letra lo justo (se recalcula al cambiar el tamaño del contenedor). */\nfunction useAjuste(ref: RefObject<HTMLElement | null>, variable: string, dep: unknown) {\n  useEffect(() => {\n    const el = ref.current, padre = el?.parentElement;\n    if (!el || !padre) return;\n    let raf = 0;\n    const ajustar = () => {\n      el.style.setProperty(variable, \"1\");\n      const natural = el.scrollWidth;\n      const libre = padre.clientWidth - 28;\n      el.style.setProperty(variable, (natural > libre && libre > 40 ? Math.max(0.35, libre / natural) : 1).toFixed(3));\n    };\n    const pedir = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(ajustar); };\n    const ro = new ResizeObserver(pedir);\n    ro.observe(padre);\n    document.fonts?.ready.then(pedir);\n    pedir();\n    return () => { cancelAnimationFrame(raf); ro.disconnect(); };\n  }, [dep]);\n}\n\n/** Texto de fuente variable cuyo grosor ondula según qué tan cerca pasa el cursor de cada letra. */\nexport function WeightWave({\n  children = \"Weight\",\n  size = 112,\n  family = \"Roboto Flex\",\n  minWeight = 100,\n  maxWeight = 1000,\n  radius = 190,\n  idle = true,\n  color = \"#F4F5F7\",\n  className = \"\",\n  style,\n}: WeightWaveProps) {\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const capaRef = useRef<HTMLSpanElement>(null);\n  const texto = String(children ?? \"\");\n  useAjuste(rootRef, \"--furin-ww-fit\", texto + size);\n  const lineas = texto.split(\"\\n\");\n  const cfg = useRef({ minWeight, maxWeight, radius, idle });\n  cfg.current = { minWeight, maxWeight, radius, idle };\n  const vivo = useRef<{ wake: () => void } | null>(null);\n\n  useEffect(() => { cargarFuente(family, Math.min(minWeight, maxWeight), Math.max(minWeight, maxWeight)); }, [family, minWeight, maxWeight]);\n\n  useEffect(() => {\n    const root = rootRef.current, capa = capaRef.current;\n    if (!root || !capa) return;\n    const letras = Array.from(capa.querySelectorAll<HTMLElement>(\"[data-l]\"));\n    const n = letras.length;\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const w = new Float32Array(n).fill(cfg.current.minWeight);\n    const ptr = { x: 0, y: 0, activo: false };\n    let visible = true, raf = 0, muerto = false, ultimo = 0;\n    const t0 = performance.now();\n\n    const escribir = () => {\n      const { minWeight: a, maxWeight: b } = cfg.current;\n      for (let i = 0; i < n; i++) {\n        const f = (w[i] - a) / Math.max(1, b - a);\n        letras[i].style.fontWeight = w[i].toFixed(1);\n        // Las letras gruesas se ven más blancas; las finas, más tenues.\n        letras[i].style.opacity = (0.62 + 0.38 * clamp(f, 0, 1)).toFixed(3);\n      }\n    };\n\n    const paso = (now: number) => {\n      raf = 0;\n      if (muerto || !visible) return;\n      const dt = Math.min(0.05, (now - (ultimo || now)) / 1000 || 0.016);\n      ultimo = now;\n      const { minWeight: a, maxWeight: b, radius: R, idle: ocioso } = cfg.current;\n      const rr = root.getBoundingClientRect();\n      const rects = letras.map((l) => l.getBoundingClientRect());\n      let px = NaN, py = NaN, fantasma = false;\n      if (ptr.activo) { px = ptr.x; py = ptr.y; }\n      else if (ocioso) {\n        // Una ola que va y viene por el texto, a velocidad de lectura.\n        const t = (now - t0) / 1000;\n        const ph = 0.5 + 0.5 * Math.sin(t * 0.85 - 1.3);\n        px = rr.left - R * 0.2 + ph * (rr.width + R * 0.4);\n        py = rr.top + rr.height / 2;\n        fantasma = true;\n      }\n      let mueve = false;\n      const k = 1 - Math.exp(-dt * 11);\n      for (let i = 0; i < n; i++) {\n        const r = rects[i];\n        let f = 0;\n        if (px === px) {\n          const dx = px - (r.left + r.width / 2);\n          const dy = fantasma ? 0 : py - (r.top + r.height / 2);\n          const d = Math.hypot(dx, dy);\n          if (d < R) f = 0.5 + 0.5 * Math.cos(Math.PI * (d / R));\n        }\n        const objetivo = a + (b - a) * f;\n        w[i] += (objetivo - w[i]) * k;\n        if (Math.abs(objetivo - w[i]) > 0.4) mueve = true;\n      }\n      escribir();\n      if (mueve || fantasma || ptr.activo) raf = requestAnimationFrame(paso);\n      else ultimo = 0;\n    };\n    const wake = () => { if (!raf && visible && !muerto && !reducido) { ultimo = 0; raf = requestAnimationFrame(paso); } };\n    vivo.current = { wake };\n\n    if (reducido) {\n      // Estado quieto: una joroba de grosor al centro del texto.\n      const { minWeight: a, maxWeight: b } = cfg.current;\n      for (let i = 0; i < n; i++) { const d = (i + 0.5) / Math.max(1, n) - 0.4; w[i] = a + (b - a) * Math.exp(-d * d * 14); }\n      escribir();\n    } else {\n      escribir();\n    }\n\n    const onMove = (e: PointerEvent) => {\n      const rr = root.getBoundingClientRect();\n      const m = cfg.current.radius * 1.2;\n      const dentro = e.clientX > rr.left - m && e.clientX < rr.right + m && e.clientY > rr.top - m && e.clientY < rr.bottom + m;\n      ptr.x = e.clientX; ptr.y = e.clientY; ptr.activo = dentro;\n      wake();\n    };\n    const onFuera = () => { ptr.activo = false; wake(); };\n    const onUp = (e: PointerEvent) => { if (e.pointerType !== \"mouse\") onFuera(); };\n    if (!reducido) {\n      window.addEventListener(\"pointermove\", onMove, { passive: true });\n      window.addEventListener(\"pointerdown\", onMove, { passive: true });\n      window.addEventListener(\"pointerup\", onUp, { passive: true });\n      document.documentElement.addEventListener(\"pointerleave\", onFuera);\n    }\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; vivo.current = null;\n      cancelAnimationFrame(raf);\n      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  }, [texto]);\n\n  const vars = {\n    \"--furin-ww-size\": `${size}px`,\n    \"--furin-ww-family\": `\"${family}\"`,\n    \"--furin-ww-min\": minWeight,\n    \"--furin-ww-max\": maxWeight,\n    \"--furin-ww-color\": color,\n  } as CSSProperties;\n\n  return (\n    <span ref={rootRef} className={`furin-weight-wave ${className}`} style={{ ...vars, ...style }}>\n      <span className=\"furin-weight-wave__sr\">{texto}</span>\n      {/* El fantasma fija el ancho con el peso máximo, así la caja no baila mientras ondula. */}\n      <span className=\"furin-weight-wave__capa furin-weight-wave__capa--fantasma\" aria-hidden=\"true\">{texto}</span>\n      <span ref={capaRef} className=\"furin-weight-wave__capa\" aria-hidden=\"true\">\n        {lineas.map((ln, li) => (\n          <span key={li} className=\"furin-weight-wave__linea\">\n            {Array.from(ln).map((ch, ci) =>\n              ch === \" \" ? <span key={ci} className=\"furin-weight-wave__esp\"> </span> : <span key={ci} data-l=\"\" className=\"furin-weight-wave__letra\">{ch}</span>,\n            )}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\nexport default WeightWave;\n",
      "type": "registry:component",
      "target": "components/furin/weight-wave/WeightWave.tsx"
    },
    {
      "path": "registry/weight-wave/WeightWave.css",
      "content": ".furin-weight-wave {\n  display: inline-grid;\n  max-width: 100%;\n  font-family: var(--furin-ww-family), \"Roboto Flex\", \"Inter\", \"SF Pro Display\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif;\n  font-size: calc(var(--furin-ww-size) * var(--furin-ww-fit, 1));\n  font-weight: var(--furin-ww-min);\n  line-height: 1.02;\n  letter-spacing: -0.03em;\n  color: var(--furin-ww-color);\n  text-align: center;\n  white-space: pre;\n  -webkit-font-smoothing: antialiased;\n}\n.furin-weight-wave__capa { grid-area: 1 / 1; display: block; }\n.furin-weight-wave__capa--fantasma { visibility: hidden; font-weight: var(--furin-ww-max); pointer-events: none; user-select: none; }\n.furin-weight-wave__linea { display: block; }\n.furin-weight-wave__letra { display: inline-block; font-weight: var(--furin-ww-min); will-change: font-weight; }\n.furin-weight-wave__esp { display: inline-block; width: 0.26em; }\n/* El texto real queda disponible para lectores de pantalla. */\n.furin-weight-wave__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n",
      "type": "registry:file",
      "target": "components/furin/weight-wave/WeightWave.css"
    }
  ]
}
