{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gust",
  "type": "registry:component",
  "title": "Gust",
  "description": "A headline whose letters get swept away by the cursor's wind like leaves, then spring back into place.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/gust/. Docs, live controls and props table: https://furin.dev/components/gust/",
  "files": [
    {
      "path": "registry/gust/Gust.tsx",
      "content": "\"use client\";\nimport { Fragment, useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./Gust.css\";\n\nexport interface GustProps {\n  /** Texto del titular: cada letra es una partícula que el viento puede arrancar. */\n  text?: string;\n  /** Fuerza de la ráfaga: qué tan lejos y qué tan rápido salen las letras; 0 apaga el viento. */\n  strength?: number;\n  /** Velocidad del cursor (px/s) a partir de la cual sopla; más lento no pasa nada. */\n  threshold?: number;\n  /** Segundos que tarda una letra en volver a su lugar. */\n  recovery?: number;\n  /** Radio de alcance de la ráfaga alrededor del cursor, en px. */\n  radius?: number;\n  /** Cuánto giran las letras al volar: 0 las deja sin girar. */\n  spin?: number;\n  /** Color de las letras (por defecto hereda el del texto). */\n  color?: string;\n  /** Color al que se tiñen las letras mientras vuelan (igual al del texto para apagarlo). */\n  accent?: string;\n  /** Clase CSS extra para el contenedor. */\n  className?: string;\n  /** Estilos en línea del contenedor (aquí va el tamaño y la fuente). */\n  style?: CSSProperties;\n}\n\n/** Una letra convertida en partícula: su sitio fijo (cx, cy) y cuánto se ha desviado de él. */\ninterface Hoja {\n  el: HTMLElement;\n  /** Centro de la letra en su lugar, en px del contenedor. */\n  cx: number;\n  cy: number;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  rot: number;\n  vrot: number;\n  /** Rasgos propios de cada hoja para que no vuelen todas igual. */\n  lift: number;\n  side: number;\n  phase: number;\n  /** Segundos desde la última vez que el viento la tocó. */\n  age: number;\n  active: boolean;\n}\n\ninterface LiveValues {\n  strength: number;\n  threshold: number;\n  recovery: number;\n  radius: number;\n  spin: number;\n}\n\nconst smoothstep = (from: number, to: number, value: number) => {\n  const t = Math.min(1, Math.max(0, (value - from) / (to - from)));\n  return t * t * (3 - 2 * t);\n};\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\nconst random = (min: number, max: number) => min + Math.random() * (max - min);\n\n/** Radio (px) alrededor de un cursor lento donde la letra se asienta de inmediato. */\nconst SETTLE_RADIUS = 70;\n/** Qué tan rápido, a lo sumo, vuela una letra (px/s). */\nconst MAX_FLIGHT = 1500;\n\n/** Un titular cuyas letras se las lleva el viento del cursor como hojas y regresan a su lugar con resorte. */\nexport function Gust({\n  text = \"Let the wind carry them away\",\n  strength = 1,\n  threshold = 650,\n  recovery = 1.8,\n  radius = 150,\n  spin = 1,\n  color,\n  accent = \"#E8452C\",\n  className = \"\",\n  style,\n}: GustProps) {\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const live = useRef<LiveValues>({ strength, threshold, recovery, radius, spin });\n\n  useEffect(() => {\n    live.current = { strength, threshold, recovery, radius, spin };\n  }, [strength, threshold, recovery, radius, spin]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    if (reducedMotion.matches) return; // el titular se queda quieto y entero\n\n    // Los eventos se escuchan en el padre: el viento sopla aunque el cursor pase junto al texto.\n    const host = root.parentElement ?? root;\n    const leaves: Hoja[] = Array.from(root.querySelectorAll<HTMLElement>(\".furin-gust__letra\")).map((el) => ({\n      el,\n      cx: 0,\n      cy: 0,\n      x: 0,\n      y: 0,\n      vx: 0,\n      vy: 0,\n      rot: 0,\n      vrot: 0,\n      lift: random(0.55, 1.35),\n      side: random(-1, 1),\n      phase: random(0, Math.PI * 2),\n      age: 0,\n      active: false,\n    }));\n\n    // Velocidad del puntero (px/s, en px de diseño) y por dónde anda.\n    const pointer = { x: 0, y: 0, vx: 0, vy: 0, active: false, lastTime: 0 };\n    let visible = true;\n    let frameId = 0;\n    let lastNow = 0;\n    let time = 0;\n    /** Si un padre escala el titular (vista previa reducida), se convierte a px de diseño. */\n    let scale = 1;\n\n    /** Lee dónde está cada letra en reposo (los offsets ignoran los transform, así que no se contaminan). */\n    const measure = () => {\n      scale = root.offsetWidth > 0 ? root.getBoundingClientRect().width / root.offsetWidth : 1;\n      for (const leaf of leaves) {\n        leaf.cx = leaf.el.offsetLeft + leaf.el.offsetWidth / 2;\n        leaf.cy = leaf.el.offsetTop + leaf.el.offsetHeight / 2;\n      }\n    };\n\n    const rest = (leaf: Hoja) => {\n      leaf.x = leaf.y = leaf.vx = leaf.vy = leaf.rot = leaf.vrot = 0;\n      leaf.active = false;\n      leaf.el.style.transform = \"\";\n      leaf.el.style.removeProperty(\"--furin-gust-tinte\");\n    };\n\n    const frame = (now: number) => {\n      frameId = requestAnimationFrame(frame);\n      const dt = Math.min((now - lastNow) / 1000, 0.05);\n      lastNow = now;\n      time += dt;\n      const { strength: power, threshold: limit, recovery: seconds, radius: reach, spin: twist } = live.current;\n\n      // El viento del cursor se apaga solo cuando el puntero se detiene.\n      const fade = Math.exp(-dt * 6);\n      pointer.vx *= fade;\n      pointer.vy *= fade;\n      if (Math.abs(pointer.vx) < 1 && Math.abs(pointer.vy) < 1) pointer.vx = pointer.vy = 0;\n      const speed = Math.hypot(pointer.vx, pointer.vy);\n\n      // Solo las ráfagas rápidas soplan: bajo el umbral no pasa nada, y se abre poco a poco hasta 1.6 veces el umbral.\n      const gust = power > 0 ? smoothstep(limit, limit * 1.6, speed) : 0;\n      const slowPointer = pointer.active && speed < limit;\n\n      // Resorte con la frecuencia que da el tiempo de regreso (asentado en ~seconds, con un poco de rebote).\n      const wn = 6 / Math.max(0.2, seconds);\n      const steps = Math.max(1, Math.ceil(dt / 0.012));\n      const h = dt / steps;\n      let moving = false;\n\n      for (const leaf of leaves) {\n        if (gust > 0) {\n          const dx = leaf.cx + leaf.x - pointer.x;\n          const dy = leaf.cy + leaf.y - pointer.y;\n          const near = clamp(1 - Math.hypot(dx, dy) / reach, 0, 1);\n          if (near > 0) {\n            // Cada hoja se deja llevar a su manera: más o menos fuerte y un poco de lado.\n            const f = near * near * gust * power;\n            const targetX = clamp(pointer.vx * 0.8 * leaf.lift - pointer.vy * 0.15 * leaf.side, -MAX_FLIGHT, MAX_FLIGHT);\n            const targetY = clamp(pointer.vy * 0.8 * leaf.lift + pointer.vx * 0.15 * leaf.side - speed * 0.12 * leaf.lift, -MAX_FLIGHT, MAX_FLIGHT);\n            const follow = 1 - Math.exp(-dt * 30 * Math.min(1, f));\n            leaf.vx += (targetX * Math.min(1.4, f + 0.3) - leaf.vx) * follow;\n            leaf.vy += (targetY * Math.min(1.4, f + 0.3) - leaf.vy) * follow;\n            leaf.vrot += (pointer.vx * 0.006 * leaf.side + leaf.side * 2) * f * twist * dt * 14;\n            leaf.age = 0;\n            leaf.active = true;\n          }\n        }\n        if (!leaf.active) continue;\n        leaf.age += dt;\n\n        // La hoja vuela libre un momento y después el resorte la trae de vuelta; un cursor lento encima la asienta ya.\n        const pull = smoothstep(seconds * 0.12, seconds * 0.65, leaf.age);\n        const here = slowPointer && Math.hypot(leaf.cx + leaf.x - pointer.x, leaf.cy + leaf.y - pointer.y) < SETTLE_RADIUS;\n        const stiff = here ? 1 : 0.05 + 0.95 * pull;\n        const k = wn * wn * stiff;\n        const c = 2 * wn * (0.15 + 0.55 * stiff);\n        const flight = 1 - pull;\n\n        for (let s = 0; s < steps; s++) {\n          // Aleteo lateral y un poco de caída mientras vuela, como una hoja.\n          const flutterX = Math.sin(time * 5 + leaf.phase) * 260 * flight;\n          const flutterY = Math.cos(time * 4 + leaf.phase * 1.3) * 120 * flight + 90 * flight;\n          leaf.vx += (-k * leaf.x - c * leaf.vx + flutterX) * h;\n          leaf.vy += (-k * leaf.y - c * leaf.vy + flutterY) * h;\n          leaf.vrot += (-k * leaf.rot - 2 * wn * 0.6 * leaf.vrot) * h;\n          leaf.x += leaf.vx * h;\n          leaf.y += leaf.vy * h;\n          leaf.rot += leaf.vrot * h;\n        }\n\n        const drift = Math.hypot(leaf.x, leaf.y);\n        const motion = Math.hypot(leaf.vx, leaf.vy);\n        if (leaf.age > 0.1 && drift < 0.05 && motion < 0.5 && Math.abs(leaf.rot) < 0.002 && Math.abs(leaf.vrot) < 0.02) {\n          rest(leaf); // vuelve a su posición exacta\n          continue;\n        }\n        moving = true;\n        const flip = Math.sin(time * 9 + leaf.phase) * clamp(motion / 500, 0, 1) * 55;\n        leaf.el.style.transform = `translate3d(${leaf.x.toFixed(2)}px, ${leaf.y.toFixed(2)}px, 0) rotate(${leaf.rot.toFixed(3)}rad) perspective(420px) rotateY(${flip.toFixed(1)}deg)`;\n        leaf.el.style.setProperty(\"--furin-gust-tinte\", clamp(drift / 60, 0, 1).toFixed(3));\n      }\n\n      // Nada en el aire y el cursor quieto: el bucle se duerme hasta el próximo soplo.\n      if (!moving && speed === 0) stop();\n    };\n\n    const start = () => {\n      if (frameId || !visible) return;\n      lastNow = performance.now();\n      frameId = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(frameId);\n      frameId = 0;\n    };\n\n    const onPointerMove = (event: PointerEvent) => {\n      const box = root.getBoundingClientRect();\n      const x = (event.clientX - box.left) / scale;\n      const y = (event.clientY - box.top) / scale;\n      const now = performance.now();\n      const elapsed = (now - pointer.lastTime) / 1000;\n      // Solo se mide la velocidad entre movimientos seguidos; el primero no sopla.\n      if (pointer.active && elapsed > 0.001 && elapsed < 0.25) {\n        const smoothing = 0.5;\n        pointer.vx += ((x - pointer.x) / elapsed - pointer.vx) * smoothing;\n        pointer.vy += ((y - pointer.y) / elapsed - pointer.vy) * smoothing;\n        pointer.vx = clamp(pointer.vx, -4000, 4000);\n        pointer.vy = clamp(pointer.vy, -4000, 4000);\n      }\n      pointer.x = x;\n      pointer.y = y;\n      pointer.lastTime = now;\n      pointer.active = true;\n      start();\n    };\n    const onPointerLeave = () => {\n      pointer.active = false;\n    };\n\n    const resizeObserver = new ResizeObserver(measure);\n    resizeObserver.observe(root);\n\n    const visibilityObserver = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) {\n        measure();\n        if (leaves.some((leaf) => leaf.active)) start();\n      } else stop();\n    });\n    visibilityObserver.observe(root);\n\n    // Si el usuario activa \"reducir movimiento\" a media sesión, las letras vuelven a su sitio y se quedan quietas.\n    const onMotionChange = () => {\n      if (!reducedMotion.matches) return;\n      stop();\n      leaves.forEach(rest);\n    };\n    reducedMotion.addEventListener(\"change\", onMotionChange);\n\n    host.addEventListener(\"pointermove\", onPointerMove);\n    host.addEventListener(\"pointerdown\", onPointerMove);\n    host.addEventListener(\"pointerleave\", onPointerLeave);\n    host.addEventListener(\"pointerup\", onPointerLeave);\n    host.addEventListener(\"pointercancel\", onPointerLeave);\n\n    // Las fuentes web cambian el ancho de las letras al cargar: se vuelve a medir.\n    document.fonts?.ready.then(measure);\n    measure();\n\n    return () => {\n      stop();\n      resizeObserver.disconnect();\n      visibilityObserver.disconnect();\n      reducedMotion.removeEventListener(\"change\", onMotionChange);\n      host.removeEventListener(\"pointermove\", onPointerMove);\n      host.removeEventListener(\"pointerdown\", onPointerMove);\n      host.removeEventListener(\"pointerleave\", onPointerLeave);\n      host.removeEventListener(\"pointerup\", onPointerLeave);\n      host.removeEventListener(\"pointercancel\", onPointerLeave);\n      leaves.forEach(rest);\n    };\n  }, [text]);\n\n  const vars = { \"--furin-gust-acento\": accent, ...(color ? { color } : {}) } as CSSProperties;\n\n  return (\n    <span ref={rootRef} className={`furin-gust ${className}`.trim()} style={{ ...vars, ...style }}>\n      {/* Texto real para lectores de pantalla: las letras sueltas de abajo se ocultan. */}\n      <span className=\"furin-gust__lectura\">{text}</span>\n      <span className=\"furin-gust__letras\" aria-hidden=\"true\">\n        {text.split(\"\\n\").map((line, l) => (\n          <Fragment key={l}>\n            {l > 0 && <br />}\n            {line\n              .split(/\\s+/)\n              .filter(Boolean)\n              .map((word, w) => (\n                <Fragment key={w}>\n                  {w > 0 && \" \"}\n                  <span className=\"furin-gust__palabra\">\n                    {Array.from(word).map((letter, i) => (\n                      <span key={i} className=\"furin-gust__letra\" data-letra={letter}>\n                        {letter}\n                      </span>\n                    ))}\n                  </span>\n                </Fragment>\n              ))}\n          </Fragment>\n        ))}\n      </span>\n    </span>\n  );\n}\n\nexport default Gust;\n",
      "type": "registry:component",
      "target": "components/furin/gust/Gust.tsx"
    },
    {
      "path": "registry/gust/Gust.css",
      "content": ".furin-gust {\n  position: relative;\n  display: block;\n  font-size: clamp(2.25rem, 7vw, 5.5rem);\n  font-weight: 800;\n  line-height: 1.04;\n  letter-spacing: -0.02em;\n  /* Las ligaduras (fi, ff...) se rompen al separar las letras. */\n  font-variant-ligatures: none;\n  /* Un gesto horizontal con el dedo sopla las letras; el vertical sigue desplazando la página. */\n  touch-action: pan-y;\n}\n\n/* Copia invisible para lectores de pantalla: siempre dice el texto completo. */\n.furin-gust__lectura {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n}\n\n/* Una palabra nunca se parte a la mitad, aunque cada letra sea su propio elemento. */\n.furin-gust__palabra {\n  display: inline-block;\n  white-space: nowrap;\n}\n\n/* La letra se mueve solo con transform: el flujo del texto no cambia nunca. */\n.furin-gust__letra {\n  position: relative;\n  display: inline-block;\n  transform-origin: 50% 58%;\n}\n\n/* La misma letra en el color de acento, encima: aparece a medida que la hoja se aleja de su sitio. */\n.furin-gust__letra::after {\n  content: attr(data-letra);\n  position: absolute;\n  top: 0;\n  left: 0;\n  color: var(--furin-gust-acento, #e8452c);\n  opacity: var(--furin-gust-tinte, 0);\n  pointer-events: none;\n}\n",
      "type": "registry:file",
      "target": "components/furin/gust/Gust.css"
    }
  ]
}
