{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"spark-waves","type":"registry:ui","title":"Spark Waves","description":"Glowing sine waves braid across the stage and shed drifting sparks from their crests. Canvas 2D.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/spark-waves.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface SparkWavesProps {\n  /** One color per wave, cycled if there are more waves than colors. */\n  colors?: string[];\n  /** Number of waves, 1 to 6. */\n  waves?: number;\n  /** Wave height in px. */\n  amplitude?: number;\n  /** Animation speed multiplier; 0 freezes the waves. */\n  speed?: number;\n  /** How eagerly the crests shed sparks. */\n  sparkRate?: number;\n  /** Core line width in px. */\n  lineWidth?: number;\n  className?: string;\n}\n\nfunction rgb(hex: string) {\n  let h = hex.replace(\"#\", \"\");\n  if (h.length === 3) h = h.replace(/./g, \"$&$&\");\n  const n = parseInt(h.padEnd(6, \"0\").slice(0, 6), 16) || 0;\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\ninterface Spark {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  life: number;\n  max: number;\n  size: number;\n  wave: number;\n  seed: number;\n}\n\nconst MAX_SPARKS = 900;\n\nexport function SparkWaves({\n  colors = [\"#22d3ee\", \"#a78bfa\", \"#f472b6\"],\n  waves = 3,\n  amplitude = 70,\n  speed = 1,\n  sparkRate = 1,\n  lineWidth = 1.5,\n  className,\n}: SparkWavesProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n  const pal = (colors.length ? colors : [\"#ffffff\"]).map(rgb);\n  const opts = useRef({ pal, waves, amplitude, speed, sparkRate, lineWidth });\n  const redraw = useRef<() => void>(undefined);\n\n  useEffect(() => {\n    opts.current = { pal: (colors.length ? colors : [\"#ffffff\"]).map(rgb), waves, amplitude, speed, sparkRate, lineWidth };\n    redraw.current?.();\n  }, [colors, waves, amplitude, speed, sparkRate, lineWidth]);\n\n  useEffect(() => {\n    const canvas = ref.current!;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const sparks: Spark[] = [];\n    let w = 0;\n    let h = 0;\n    let t = 4;\n    let raf = 0;\n    let last = 0;\n\n    // Normalized wave shape in [-1, 1]; +1 is a crest (highest on screen).\n    const shape = (x: number, i: number) =>\n      Math.sin(x * 0.0062 + t * (0.9 + i * 0.17) + i * 2.1) * 0.62 +\n      Math.sin(x * 0.0023 - t * (0.45 + i * 0.08) + i * 4.3) * 0.38;\n    const baseY = (i: number, n: number) => h * 0.5 + (i - (n - 1) / 2) * Math.min(26, h * 0.05);\n\n    const step = (dt: number) => {\n      const o = opts.current;\n      const n = Math.min(6, Math.max(1, Math.round(o.waves)));\n      t += dt;\n      // Sample crests and shed sparks where the wave peaks.\n      const samples = Math.ceil(w / 14);\n      for (let i = 0; i < n; i++) {\n        for (let k = 0; k < samples; k++) {\n          if (sparks.length >= MAX_SPARKS) break;\n          const x = Math.random() * w;\n          const c = shape(x, i);\n          if (c < 0.55) continue;\n          const chance = Math.pow((c - 0.55) / 0.45, 3) * o.sparkRate * dt * 9;\n          if (Math.random() > chance) continue;\n          const slope = shape(x + 1, i) - c;\n          sparks.push({\n            x,\n            y: baseY(i, n) - c * o.amplitude,\n            vx: (Math.random() - 0.5) * 50 - slope * 900,\n            vy: -(30 + Math.random() * 90),\n            life: 0,\n            max: 0.7 + Math.random() * 1.6,\n            size: 0.5 + Math.random() * 1.3,\n            wave: i,\n            seed: Math.random() * 10,\n          });\n        }\n      }\n      for (let i = sparks.length - 1; i >= 0; i--) {\n        const s = sparks[i];\n        s.life += dt;\n        s.vy += 38 * dt;\n        s.vx *= Math.exp(-dt * 0.8);\n        s.vx += Math.sin(t * 2 + s.seed) * 12 * dt;\n        s.x += s.vx * dt;\n        s.y += s.vy * dt;\n        if (s.life > s.max || s.wave >= n) sparks.splice(i, 1);\n      }\n    };\n\n    const render = () => {\n      const o = opts.current;\n      const n = Math.min(6, Math.max(1, Math.round(o.waves)));\n      ctx.clearRect(0, 0, w, h);\n      ctx.globalCompositeOperation = \"lighter\";\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      for (let i = 0; i < n; i++) {\n        const [r, g, b] = o.pal[i % o.pal.length];\n        const y0 = baseY(i, n);\n        ctx.beginPath();\n        for (let x = -8; x <= w + 8; x += 6) {\n          const y = y0 - shape(x, i) * o.amplitude;\n          if (x < 0) ctx.moveTo(x, y);\n          else ctx.lineTo(x, y);\n        }\n        // Layered strokes fake a bloom without shadowBlur.\n        ctx.strokeStyle = `rgba(${r},${g},${b},0.05)`;\n        ctx.lineWidth = o.lineWidth * 10;\n        ctx.stroke();\n        ctx.strokeStyle = `rgba(${r},${g},${b},0.16)`;\n        ctx.lineWidth = o.lineWidth * 4;\n        ctx.stroke();\n        ctx.strokeStyle = `rgba(${(r + 255 * 2) / 3 | 0},${(g + 255 * 2) / 3 | 0},${(b + 255 * 2) / 3 | 0},0.9)`;\n        ctx.lineWidth = o.lineWidth;\n        ctx.stroke();\n      }\n      for (const s of sparks) {\n        const [r, g, b] = o.pal[s.wave % o.pal.length];\n        const p = s.life / s.max;\n        const a = (1 - p) * (0.65 + 0.35 * Math.sin(s.life * 25 + s.seed * 5));\n        ctx.fillStyle = `rgba(${r},${g},${b},${a * 0.25})`;\n        ctx.beginPath();\n        ctx.arc(s.x, s.y, s.size * 3.2, 0, Math.PI * 2);\n        ctx.fill();\n        ctx.fillStyle = `rgba(${(r + 255) >> 1},${(g + 255) >> 1},${(b + 255) >> 1},${a})`;\n        ctx.beginPath();\n        ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);\n        ctx.fill();\n      }\n      ctx.globalCompositeOperation = \"source-over\";\n    };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;\n      last = now;\n      step(dt * opts.current.speed);\n      render();\n      raf = requestAnimationFrame(loop);\n    };\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const first = !w;\n      w = canvas.offsetWidth;\n      h = canvas.offsetHeight;\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      // Pre-warm so sparks are already in the air on the first frame.\n      if (first && w) for (let i = 0; i < 90; i++) step(1 / 60);\n      render();\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    const io = new IntersectionObserver(([e]) => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n      if (e.isIntersecting && !reduced) raf = requestAnimationFrame(loop);\n    });\n    io.observe(canvas);\n    redraw.current = () => {\n      if (!raf) render();\n    };\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      redraw.current = undefined;\n    };\n  }, []);\n\n  const mask = \"linear-gradient(to right, transparent, #000 15%, #000 85%, transparent)\";\n  return (\n    <canvas\n      ref={ref}\n      aria-hidden\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        width: \"100%\",\n        height: \"100%\",\n        pointerEvents: \"none\",\n        maskImage: mask,\n        WebkitMaskImage: mask,\n      }}\n    />\n  );\n}\n"}]}