{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streamers","type":"registry:ui","title":"Streamers","description":"Silky, twisting ribbons that chase the cursor on springs and fan out as they lag behind.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/streamers.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface StreamersProps {\n  /** One ribbon per color, front to back. */\n  colors?: string[];\n  /** Head width in px. */\n  thickness?: number;\n  /** Points per ribbon; longer tails at higher values. */\n  length?: number;\n  /** Spring pull toward the cursor (0-1). Lower lags more. */\n  spring?: number;\n  /** Width ripples like a twisting ribbon. */\n  twist?: boolean;\n  /** Ribbons wander on their own while the cursor is away. */\n  idle?: boolean;\n  className?: string;\n}\n\nfunction hexToRgb(hex: string) {\n  const n = parseInt(hex.replace(\"#\", \"\").padEnd(6, \"0\").slice(0, 6), 16);\n  return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;\n}\n\nexport function Streamers({\n  colors = [\"#c6ff3d\", \"#22d3ee\", \"#a78bfa\", \"#f472b6\"],\n  thickness = 22,\n  length = 40,\n  spring = 0.05,\n  twist = true,\n  idle = true,\n  className,\n}: StreamersProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n  // A string key keeps a fresh-but-equal colors array from restarting the effect.\n  const colorKey = colors.join(\",\");\n\n  useEffect(() => {\n    const canvas = ref.current!;\n    const host = canvas.parentElement ?? canvas;\n    const ctx = canvas.getContext(\"2d\")!;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let w = 0;\n    let h = 0;\n    let raf = 0;\n    let visible = true;\n    const mouse = { x: 0, y: 0, at: -1e9 };\n    // Back ribbons are softer springs, so the bundle fans out when the cursor moves.\n    const ribbons = colorKey.split(\",\").map((c, k) => ({\n      rgb: hexToRgb(c),\n      k,\n      stiffness: spring * (1 - k * 0.14),\n      damping: 0.8 - k * 0.025,\n      width: thickness * (1 - k * 0.1),\n      vx: 0,\n      vy: 0,\n      pts: [] as { x: number; y: number }[],\n    }));\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      w = canvas.offsetWidth;\n      h = canvas.offsetHeight;\n      canvas.width = w * dpr;\n      canvas.height = h * dpr;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      for (const r of ribbons)\n        if (!r.pts.length) r.pts = Array.from({ length }, () => ({ x: w / 2, y: h / 2 }));\n    };\n\n    const frame = (now: number) => {\n      const t = now / 1000;\n      let tx = mouse.x;\n      let ty = mouse.y;\n      if (idle && now - mouse.at > 1500) {\n        tx = w / 2 + Math.sin(t * 0.8) * w * 0.3;\n        ty = h / 2 + Math.sin(t * 1.3) * Math.cos(t * 0.5) * h * 0.3;\n      }\n      ctx.clearRect(0, 0, w, h);\n      ctx.globalCompositeOperation = \"lighter\";\n      const left: number[] = [];\n      const right: number[] = [];\n      for (const r of [...ribbons].reverse()) {\n        const p = r.pts;\n        const head = p[0];\n        // Each ribbon circles the target on its own orbit, so they keep flowing even when the cursor rests.\n        const orbit = 14 + r.k * 12;\n        const ox = Math.cos(t * (2.2 - r.k * 0.3) + r.k * 1.7) * orbit;\n        const oy = Math.sin(t * (2.2 - r.k * 0.3) * 1.3 + r.k * 1.7) * orbit;\n        r.vx = (r.vx + (tx + ox - head.x) * r.stiffness) * r.damping;\n        r.vy = (r.vy + (ty + oy - head.y) * r.stiffness) * r.damping;\n        head.x += r.vx;\n        head.y += r.vy;\n        for (let i = 1; i < p.length; i++) {\n          p[i].x += (p[i - 1].x - p[i].x) * 0.3;\n          p[i].y += (p[i - 1].y - p[i].y) * 0.3;\n        }\n        left.length = 0;\n        right.length = 0;\n        const n = p.length;\n        for (let i = 0; i < n; i++) {\n          const a = p[Math.max(0, i - 1)];\n          const b = p[Math.min(n - 1, i + 1)];\n          const dx = a.x - b.x;\n          const dy = a.y - b.y;\n          const len = Math.hypot(dx, dy) || 1;\n          let hw = (r.width / 2) * Math.pow(1 - i / n, 0.9);\n          if (twist) hw *= 0.35 + 0.65 * Math.abs(Math.sin(i * 0.22 - t * 3 + r.k));\n          left.push(p[i].x - (dy / len) * hw, p[i].y + (dx / len) * hw);\n          right.push(p[i].x + (dy / len) * hw, p[i].y - (dx / len) * hw);\n        }\n        ctx.beginPath();\n        ctx.moveTo(left[0], left[1]);\n        for (let i = 2; i < left.length - 2; i += 2)\n          ctx.quadraticCurveTo(left[i], left[i + 1], (left[i] + left[i + 2]) / 2, (left[i + 1] + left[i + 3]) / 2);\n        for (let i = right.length - 2; i > 1; i -= 2)\n          ctx.quadraticCurveTo(right[i], right[i + 1], (right[i] + right[i - 2]) / 2, (right[i + 1] + right[i - 1]) / 2);\n        ctx.closePath();\n        const grad = ctx.createLinearGradient(p[0].x, p[0].y, p[n - 1].x, p[n - 1].y);\n        grad.addColorStop(0, `rgba(${r.rgb},0.95)`);\n        grad.addColorStop(1, `rgba(${r.rgb},0)`);\n        ctx.fillStyle = grad;\n        ctx.fill();\n      }\n      ctx.globalCompositeOperation = \"source-over\";\n    };\n\n    const loop = (now: number) => {\n      frame(now);\n      raf = visible ? requestAnimationFrame(loop) : 0;\n    };\n\n    const onMove = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      mouse.x = e.clientX - rect.left;\n      mouse.y = e.clientY - rect.top;\n      mouse.at = performance.now();\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    resize();\n    if (reduced) return () => ro.disconnect();\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible && !raf) raf = requestAnimationFrame(loop);\n    });\n    io.observe(canvas);\n    host.addEventListener(\"pointermove\", onMove, { passive: true });\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      host.removeEventListener(\"pointermove\", onMove);\n    };\n  }, [colorKey, thickness, length, spring, twist, idle]);\n\n  return (\n    <canvas\n      ref={ref}\n      aria-hidden\n      className={className}\n      style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n    />\n  );\n}\n"}]}