{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bead-bin","type":"registry:ui","title":"Bead Bin","description":"Glossy colored balls tumble in with gravity, collide and pile up; sweep the cursor through to shove them around. Canvas 2D.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/bead-bin.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface BeadBinProps {\n  /** Number of balls. */\n  count?: number;\n  /** Ball colors, picked at random. */\n  colors?: string[];\n  /** Ball size multiplier. */\n  size?: number;\n  /** Gravity multiplier; 0 lets the balls float. */\n  gravity?: number;\n  /** Bounciness from 0 (dead) to 1 (elastic). */\n  bounce?: number;\n  /** The cursor shoves the balls around. */\n  interactive?: boolean;\n  /** Px radius of the cursor's push. */\n  cursorRadius?: number;\n  className?: string;\n}\n\ninterface Ball {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  sprite: number;\n  inside: boolean;\n}\n\nconst SPRITE = 160;\nconst STEP = 1 / 120;\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\n/** Pre-renders one glossy ball so each frame is a cheap drawImage. */\nfunction makeSprite(color: string) {\n  const c = document.createElement(\"canvas\");\n  c.width = c.height = SPRITE;\n  const g = c.getContext(\"2d\")!;\n  const [r, gr, b] = rgb(color);\n  const tone = (k: number) =>\n    k >= 0\n      ? `rgb(${r + (255 - r) * k},${gr + (255 - gr) * k},${b + (255 - b) * k})`\n      : `rgb(${r * (1 + k)},${gr * (1 + k)},${b * (1 + k)})`;\n  const R = SPRITE / 2;\n  const body = g.createRadialGradient(R * 0.7, R * 0.6, 0, R, R, R);\n  body.addColorStop(0, tone(0.5));\n  body.addColorStop(0.35, tone(0));\n  body.addColorStop(0.82, tone(-0.45));\n  body.addColorStop(1, tone(-0.2));\n  g.fillStyle = body;\n  g.beginPath();\n  g.arc(R, R, R, 0, Math.PI * 2);\n  g.fill();\n  const spec = g.createRadialGradient(R * 0.66, R * 0.54, 0, R * 0.66, R * 0.54, R * 0.36);\n  spec.addColorStop(0, \"rgba(255,255,255,0.95)\");\n  spec.addColorStop(0.35, \"rgba(255,255,255,0.35)\");\n  spec.addColorStop(1, \"rgba(255,255,255,0)\");\n  g.fillStyle = spec;\n  g.fill();\n  const bounceLight = g.createRadialGradient(R * 1.3, R * 1.45, 0, R * 1.3, R * 1.45, R * 0.7);\n  bounceLight.addColorStop(0, \"rgba(255,255,255,0.22)\");\n  bounceLight.addColorStop(1, \"rgba(255,255,255,0)\");\n  g.fillStyle = bounceLight;\n  g.fill();\n  return c;\n}\n\nexport function BeadBin({\n  count = 60,\n  colors = [\"#c6ff3d\", \"#22d3ee\", \"#a78bfa\", \"#f472b6\", \"#fbbf24\"],\n  size = 1,\n  gravity = 1,\n  bounce = 0.7,\n  interactive = true,\n  cursorRadius = 90,\n  className,\n}: BeadBinProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n  const opts = useRef({ gravity, bounce, interactive, cursorRadius });\n  const colorKey = colors.join(\",\");\n\n  useEffect(() => {\n    opts.current = { gravity, bounce, interactive, cursorRadius };\n  }, [gravity, bounce, interactive, cursorRadius]);\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 palette = colorKey ? colorKey.split(\",\") : [\"#ffffff\"];\n    const sprites = palette.map(makeSprite);\n    const balls: Ball[] = [];\n    const cursor = { x: -1e4, y: -1e4, vx: 0, vy: 0, t: 0 };\n    let w = 0;\n    let h = 0;\n    let raf = 0;\n    let visible = true;\n    let last = 0;\n    let acc = 0;\n\n    const spawn = () => {\n      const base = Math.sqrt((w * h * 0.42) / (Math.max(count, 1) * Math.PI)) * size;\n      const floating = opts.current.gravity === 0;\n      for (let i = 0; i < count; i++) {\n        const r = Math.max(4, base * (0.7 + Math.random() * 0.6));\n        balls.push({\n          x: r + Math.random() * Math.max(w - 2 * r, 1),\n          y: floating ? r + Math.random() * Math.max(h - 2 * r, 1) : -r - Math.random() * h * 1.5,\n          vx: (Math.random() - 0.5) * 200,\n          vy: floating ? (Math.random() - 0.5) * 200 : 0,\n          r,\n          sprite: i % sprites.length,\n          inside: floating,\n        });\n      }\n    };\n\n    const step = () => {\n      const { gravity: g, bounce: e, interactive: pushy, cursorRadius: cr } = opts.current;\n      const drag = g === 0 ? 0.9995 : 0.999;\n      for (const b of balls) {\n        b.vy += 1800 * g * STEP;\n        b.vx *= drag;\n        b.vy *= drag;\n        b.x += b.vx * STEP;\n        b.y += b.vy * STEP;\n        if (b.x < b.r) {\n          b.x = b.r;\n          b.vx = Math.abs(b.vx) * e;\n        }\n        if (b.x > w - b.r) {\n          b.x = w - b.r;\n          b.vx = -Math.abs(b.vx) * e;\n        }\n        if (b.y > h - b.r) {\n          b.y = h - b.r;\n          b.vy = -Math.abs(b.vy) * e;\n          b.vx *= 0.985;\n        }\n        if (b.y > b.r) b.inside = true;\n        if (b.inside && b.y < b.r) {\n          b.y = b.r;\n          b.vy = Math.abs(b.vy) * e;\n        }\n        if (pushy) {\n          const dx = b.x - cursor.x;\n          const dy = b.y - cursor.y;\n          const min = b.r + cr;\n          const d2 = dx * dx + dy * dy;\n          if (d2 < min * min && d2 > 1e-6) {\n            const d = Math.sqrt(d2);\n            const nx = dx / d;\n            const ny = dy / d;\n            b.x = cursor.x + nx * min;\n            b.y = cursor.y + ny * min;\n            const vn = (b.vx - cursor.vx) * nx + (b.vy - cursor.vy) * ny;\n            if (vn < 0) {\n              b.vx -= (1 + e) * vn * nx;\n              b.vy -= (1 + e) * vn * ny;\n            }\n          }\n        }\n      }\n      // ponytail: O(n²) pair test, fine to a few hundred balls; use a spatial grid beyond that.\n      for (let i = 0; i < balls.length; i++) {\n        const a = balls[i];\n        for (let j = i + 1; j < balls.length; j++) {\n          const b = balls[j];\n          const dx = a.x - b.x;\n          const dy = a.y - b.y;\n          const min = a.r + b.r;\n          const d2 = dx * dx + dy * dy;\n          if (d2 >= min * min || d2 < 1e-6) continue;\n          const d = Math.sqrt(d2);\n          const nx = dx / d;\n          const ny = dy / d;\n          const ma = a.r * a.r;\n          const mb = b.r * b.r;\n          const push = (min - d) / (ma + mb);\n          a.x += nx * push * mb;\n          a.y += ny * push * mb;\n          b.x -= nx * push * ma;\n          b.y -= ny * push * ma;\n          const vn = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;\n          if (vn < 0) {\n            const j2 = (-(1 + e) * vn) / (1 / ma + 1 / mb);\n            a.vx += (j2 / ma) * nx;\n            a.vy += (j2 / ma) * ny;\n            b.vx -= (j2 / mb) * nx;\n            b.vy -= (j2 / mb) * ny;\n          }\n        }\n      }\n    };\n\n    const draw = () => {\n      ctx.clearRect(0, 0, w, h);\n      for (const b of balls) ctx.drawImage(sprites[b.sprite], b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);\n    };\n\n    const loop = (now: number) => {\n      acc += Math.min(now - last, 50) / 1000;\n      last = now;\n      while (acc >= STEP) {\n        step();\n        acc -= STEP;\n      }\n      cursor.vx *= 0.85;\n      cursor.vy *= 0.85;\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n    const play = () => {\n      if (raf || !visible || reduced || !w) return;\n      last = performance.now();\n      raf = requestAnimationFrame(loop);\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 = 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      if (!balls.length && w && h) {\n        spawn();\n        // Reduced motion: let the pile settle off-screen, then show one still frame.\n        if (reduced) for (let i = 0; i < 600; i++) step();\n      }\n      for (const b of balls) {\n        b.x = Math.min(Math.max(b.x, b.r), Math.max(w - b.r, b.r));\n        if (b.inside) b.y = Math.min(Math.max(b.y, b.r), Math.max(h - b.r, b.r));\n      }\n      draw();\n      play();\n    };\n\n    const onMove = (e: PointerEvent) => {\n      const r = canvas.getBoundingClientRect();\n      const x = e.clientX - r.left;\n      const y = e.clientY - r.top;\n      const dt = Math.max((e.timeStamp - cursor.t) / 1000, 1 / 240);\n      if (cursor.x > -1e3 && dt < 0.2) {\n        cursor.vx = Math.max(-3000, Math.min(3000, (x - cursor.x) / dt));\n        cursor.vy = Math.max(-3000, Math.min(3000, (y - cursor.y) / dt));\n      }\n      cursor.x = x;\n      cursor.y = y;\n      cursor.t = e.timeStamp;\n    };\n    const onLeave = () => {\n      cursor.x = cursor.y = -1e4;\n      cursor.vx = cursor.vy = 0;\n    };\n\n    const target = canvas.parentElement ?? canvas;\n    target.addEventListener(\"pointermove\", onMove, { passive: true });\n    target.addEventListener(\"pointerleave\", onLeave);\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) play();\n      else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    });\n    io.observe(canvas);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      target.removeEventListener(\"pointermove\", onMove);\n      target.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [count, size, colorKey]);\n\n  return (\n    <canvas\n      ref={ref}\n      aria-hidden\n      className={className}\n      style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", pointerEvents: \"none\" }}\n    />\n  );\n}\n"}]}