{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confetti",
  "type": "registry:ui",
  "title": "Confetti",
  "description": "Celebration confetti: seeded chips and streamers fall under gravity with per-piece spin, tumble (foreshortening flip with a shadowed backface) and flutter. Opens with an upward burst on mount and keeps firing occasional puffs, then settles into a gentle endless drift. Festive-but-tasteful density, colors/speed/count knobs. Canvas 2D, seeded, offscreen-paused, reduced-motion-safe (static settled scatter).",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-visibility-pause.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/backgrounds/confetti/confetti.tsx",
      "type": "registry:ui",
      "target": "components/crucible/confetti.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\n\nexport interface ConfettiProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * Confetti palette — pieces are colored evenly across this list. Defaults to a\n   * tasteful festive spread (pink, gold, mint, blue, violet, peach) on near-black.\n   * Pass a tighter set (e.g. two brand tones) for on-brand celebration, or a\n   * single-hue array for monochrome fall.\n   * @default [\"#ff7eb6\", \"#ffd166\", \"#68e0a8\", \"#6bb8ff\", \"#b892ff\", \"#ff9a6b\"]\n   */\n  colors?: string[];\n  /** Fall / flutter / burst-cadence multiplier. 1 = default, 2 = twice as lively. @default 1 */\n  speed?: number;\n  /** Piece-count multiplier over the area-based default, 0.2–3. @default 1 */\n  count?: number;\n  /** Overall piece opacity and sheen, 0–2. @default 1 */\n  intensity?: number;\n  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */\n  dpr?: number;\n  /** Freeze at a static, settled scatter of confetti. @default false */\n  paused?: boolean;\n}\n\nconst DEFAULT_COLORS: string[] = [\n  \"#ff7eb6\",\n  \"#ffd166\",\n  \"#68e0a8\",\n  \"#6bb8ff\",\n  \"#b892ff\",\n  \"#ff9a6b\",\n];\n\n/** Deterministic mulberry32 — seeded so the scatter is identical SSR intent ↔ CSR. */\nfunction mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst SEED = 0x0c0ffee1;\n\nfunction hexToRgb(hex: string): [number, number, number] {\n  let h = hex.replace(\"#\", \"\").trim();\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  const int = parseInt(h, 16);\n  if (Number.isNaN(int) || h.length !== 6) return [255, 126, 182];\n  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];\n}\n\n// --- physics constants (CSS px, seconds) -----------------------------------\nconst GRAVITY = 360; // downward accel\nconst DRAG = 1.6; // velocity damping/s → sets terminal fall speed (~GRAVITY/DRAG)\nconst MARGIN = 44; // offscreen spawn/recycle margin\nconst STREAMER_RATIO = 0.22; // fraction of pieces that are long thin ribbons\nconst BURST_FRACTION = 0.3; // fraction of pieces reserved for upward burst puffs\nconst PARKED_Y = 1e6; // sentinel: burst piece resting off-stage awaiting launch\n\ninterface Piece {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  rot: number; // spin angle (rad)\n  spin: number; // rad/s\n  wob: number; // flutter/tumble phase (rad)\n  wobFreq: number; // rad/s\n  sway: number; // horizontal flutter amplitude, px/s\n  w: number;\n  h: number;\n  colorIdx: number;\n  alpha: number;\n  streamer: boolean;\n  isBurst: boolean;\n  parked: boolean; // burst piece resting off-stage until next puff\n  // Frozen settled pose — the reduced-motion / paused frame.\n  staticX: number;\n  staticY: number;\n  staticRot: number;\n  staticWob: number;\n}\n\ninterface Internals {\n  ctx: CanvasRenderingContext2D;\n  width: number;\n  height: number;\n  pieces: Piece[];\n  palette: [number, number, number][];\n  rng: () => number; // persistent stream driving bursts\n  speed: number;\n  intensity: number;\n  clock: number; // sim-seconds accumulator (scaled by speed)\n  nextBurst: number; // clock time of the next puff\n  burstCursor: number; // rotating index into the burst pool\n  burstCount: number; // pieces launched per puff\n  fallCount: number; // number of ambient falling pieces\n  bg: string;\n}\n\n/** Fresh ambient faller descending from just above the top edge. */\nfunction spawnFall(p: Piece, w: number, rng: () => number) {\n  p.x = rng() * w;\n  p.y = -MARGIN - rng() * MARGIN * 2;\n  p.vx = (rng() - 0.5) * 40;\n  p.vy = 30 + rng() * 40;\n  p.spin = (rng() - 0.5) * 10;\n  p.wobFreq = 4 + rng() * 5;\n  p.parked = false;\n}\n\n/** Launch a parked burst piece upward from an off-bottom origin as a puff. */\nfunction launchBurst(p: Piece, originX: number, height: number, rng: () => number) {\n  // Fan of upward velocities from a popper just below the frame.\n  const spread = (rng() - 0.5) * 0.95; // angle off vertical, rad (~±27°)\n  const speed = 440 + rng() * 300;\n  p.x = originX + (rng() - 0.5) * 24;\n  p.y = height + MARGIN + rng() * 12;\n  p.vx = Math.sin(spread) * speed + (rng() - 0.5) * 90;\n  p.vy = -Math.cos(spread) * speed;\n  p.spin = (rng() - 0.5) * 14;\n  p.wobFreq = 5 + rng() * 6;\n  p.parked = false;\n}\n\n/** Fire one upward puff — relaunch a batch of parked burst pieces from a seeded origin. */\nfunction fireBurst(it: Internals) {\n  const originX = it.width * (0.18 + it.rng() * 0.64);\n  let launched = 0;\n  for (let scan = 0; scan < it.pieces.length && launched < it.burstCount; scan++) {\n    const idx = it.fallCount + (it.burstCursor % (it.pieces.length - it.fallCount || 1));\n    it.burstCursor++;\n    const p = it.pieces[idx];\n    if (!p || !p.isBurst || !p.parked) continue;\n    launchBurst(p, originX, it.height, it.rng);\n    launched++;\n  }\n}\n\nfunction buildPieces(\n  w: number,\n  h: number,\n  total: number,\n  palLen: number\n): { pieces: Piece[]; fallCount: number } {\n  const rng = mulberry32(SEED ^ 0x9e3779b9);\n  const burstN = Math.round(total * BURST_FRACTION);\n  const fallCount = total - burstN;\n  const pieces: Piece[] = [];\n\n  for (let i = 0; i < total; i++) {\n    const isBurst = i >= fallCount;\n    const streamer = rng() < STREAMER_RATIO;\n    const pw = streamer ? 3 + rng() * 2 : 6 + rng() * 5;\n    const ph = streamer ? 16 + rng() * 14 : 8 + rng() * 6;\n    const p: Piece = {\n      x: rng() * w,\n      // Ambient fallers pre-scatter across the whole frame so it's never empty;\n      // burst pieces park off-stage until their first puff.\n      y: isBurst ? PARKED_Y : rng() * h,\n      vx: (rng() - 0.5) * 40,\n      vy: 30 + rng() * 60,\n      rot: rng() * Math.PI * 2,\n      spin: (rng() - 0.5) * 10,\n      wob: rng() * Math.PI * 2,\n      wobFreq: 4 + rng() * (streamer ? 3 : 5),\n      sway: (streamer ? 34 : 20) * (0.7 + rng() * 0.6),\n      w: pw,\n      h: ph,\n      colorIdx: i % palLen,\n      alpha: 0.86 + rng() * 0.14,\n      streamer,\n      isBurst,\n      parked: isBurst,\n      // Settled scatter, biased toward the bottom (sqrt pushes mass downward).\n      staticX: rng() * w,\n      staticY: h * (0.06 + 0.92 * Math.sqrt(rng())),\n      staticRot: rng() * Math.PI * 2,\n      staticWob: rng() * Math.PI * 2,\n    };\n    pieces.push(p);\n  }\n  return { pieces, fallCount };\n}\n\n/** Draw one piece as a tumbling foreshortened chip; backface reads darker for depth. */\nfunction drawPiece(\n  it: Internals,\n  p: Piece,\n  x: number,\n  y: number,\n  rot: number,\n  wob: number\n) {\n  const { ctx } = it;\n  const rgb = it.palette[p.colorIdx % it.palette.length];\n  const fscale = Math.cos(wob); // tumble: +1 face-on, 0 edge-on, -1 backface\n  const sy = 0.26 + 0.74 * Math.abs(fscale);\n  const back = fscale < 0;\n  const shade = back ? 0.5 : 1; // the reverse of the chip is in shadow\n  const a = Math.min(p.alpha * it.intensity, 1);\n  if (a <= 0.01) return;\n\n  ctx.save();\n  ctx.translate(x, y);\n  ctx.rotate(rot);\n  ctx.scale(1, sy);\n  ctx.fillStyle = `rgba(${(rgb[0] * shade) | 0}, ${(rgb[1] * shade) | 0}, ${(rgb[2] * shade) | 0}, ${a})`;\n  ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);\n  // Front-face sheen — a bright sliver along the leading edge.\n  if (!back) {\n    ctx.fillStyle = `rgba(255,255,255,${Math.min(a * 0.16, 1)})`;\n    ctx.fillRect(-p.w / 2, -p.h / 2, p.w, Math.max(p.h * 0.28, 1));\n  }\n  ctx.restore();\n}\n\n/** One frame. `useStatic` freezes every piece at its settled pose. */\nfunction draw(it: Internals, dt: number, useStatic: boolean) {\n  const { ctx, width, height } = it;\n\n  ctx.globalCompositeOperation = \"source-over\";\n  ctx.fillStyle = it.bg;\n  ctx.fillRect(0, 0, width, height);\n\n  if (useStatic) {\n    for (let i = 0; i < it.pieces.length; i++) {\n      const p = it.pieces[i];\n      drawPiece(it, p, p.staticX, p.staticY, p.staticRot, p.staticWob);\n    }\n    return;\n  }\n\n  const dts = dt * it.speed;\n\n  // ------------------------------------------------------------ burst cadence --\n  it.clock += dts;\n  if (it.clock >= it.nextBurst) {\n    fireBurst(it);\n    it.nextBurst = it.clock + 2.6 + it.rng() * 2.2;\n  }\n\n  const damp = Math.exp(-DRAG * dts);\n\n  for (let i = 0; i < it.pieces.length; i++) {\n    const p = it.pieces[i];\n    if (p.parked) continue; // burst piece resting off-stage\n\n    p.vy = (p.vy + GRAVITY * dts) * damp;\n    p.vx *= damp;\n    p.wob += p.wobFreq * dts;\n    p.rot += p.spin * dts;\n    p.x += p.vx * dts + Math.sin(p.wob) * p.sway * dts;\n    p.y += p.vy * dts;\n\n    // Horizontal wrap so drift never leaves a bare edge.\n    if (p.x < -MARGIN) p.x += width + MARGIN * 2;\n    else if (p.x > width + MARGIN) p.x -= width + MARGIN * 2;\n\n    // Recycle once fully below the frame.\n    if (p.y > height + MARGIN) {\n      if (p.isBurst) {\n        p.parked = true;\n        p.y = PARKED_Y;\n      } else {\n        spawnFall(p, width, it.rng);\n      }\n      continue;\n    }\n\n    drawPiece(it, p, p.x, p.y, p.rot, p.wob);\n  }\n}\n\n/** Neutral near-black bed — festive chips read cleanest on a quiet ground. */\nconst BG = \"rgb(9, 9, 11)\";\n\n/**\n * Confetti — a celebration background. Seeded rectangular chips and thin\n * streamers fall under gravity with air-drag terminal speed, each one spinning\n * and tumbling on its own axis (foreshortening flip with a shadowed backface)\n * while it flutters side to side. It opens with an upward burst on mount and\n * keeps firing occasional puffs from off the bottom edge, so the field pops and\n * then settles into a gentle, endless drift. Festive-but-tasteful density by\n * default; retint via `colors`. Canvas 2D: seeded and SSR-safe, paused\n * offscreen and on hidden tabs, reduced-motion-safe (a static settled scatter).\n * Drop it inside any `relative` container.\n */\nexport function Confetti({\n  colors = DEFAULT_COLORS,\n  speed = 1,\n  count = 1,\n  intensity = 1,\n  dpr = 2,\n  paused = false,\n  className,\n  ...props\n}: ConfettiProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const internalsRef = React.useRef<Internals | null>(null);\n  const reducedMotion = useReducedMotion();\n  const staticFrame = paused || reducedMotion;\n  const staticFrameRef = React.useRef(staticFrame);\n  staticFrameRef.current = staticFrame;\n  const lastElapsedRef = React.useRef(0);\n\n  const cnt = Math.min(Math.max(count, 0.2), 3);\n  const spd = Math.min(Math.max(speed, 0.1), 4);\n  const colorsKey = colors.join(\",\");\n\n  const tick = React.useCallback((elapsed: number) => {\n    const it = internalsRef.current;\n    if (!it) return;\n    let dt = elapsed - lastElapsedRef.current;\n    lastElapsedRef.current = elapsed;\n    if (dt > 0.1) dt = 0.1; // clamp tab-restore jumps\n    if (dt < 0) dt = 0;\n    draw(it, dt, staticFrameRef.current);\n  }, []);\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n\n    const ctx = canvas.getContext(\"2d\", { alpha: false });\n    if (!ctx) return;\n\n    const effectiveDpr = Math.min(\n      typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1,\n      Math.min(Math.max(dpr, 1), 2)\n    );\n\n    const source = colors.length ? colors : DEFAULT_COLORS;\n    const palette = source.map(hexToRgb);\n\n    const it: Internals = {\n      ctx,\n      width: 0,\n      height: 0,\n      pieces: [],\n      palette,\n      rng: mulberry32(SEED ^ 0x85ebca6b),\n      speed: spd,\n      intensity,\n      clock: 0,\n      nextBurst: 0, // fire the opening burst on the first animated frame\n      burstCursor: 0,\n      burstCount: 0,\n      fallCount: 0,\n      bg: BG,\n    };\n    internalsRef.current = it;\n\n    const onContextLost = (e: Event) => e.preventDefault();\n    canvas.addEventListener(\"contextlost\", onContextLost);\n\n    const resize = () => {\n      const w = Math.max(container.clientWidth, 1);\n      const h = Math.max(container.clientHeight, 1);\n      canvas.width = Math.round(w * effectiveDpr);\n      canvas.height = Math.round(h * effectiveDpr);\n      canvas.style.width = \"100%\";\n      canvas.style.height = \"100%\";\n      ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);\n\n      const area = w * h;\n      const base = Math.min(190, Math.max(34, Math.round((area / 8200) * cnt)));\n      const { pieces, fallCount } = buildPieces(w, h, base, palette.length);\n\n      it.width = w;\n      it.height = h;\n      it.pieces = pieces;\n      it.fallCount = fallCount;\n      it.burstCount = Math.max(6, Math.round((base - fallCount) * 0.42));\n      it.clock = 0;\n      it.nextBurst = 0;\n      it.burstCursor = 0;\n\n      // Never flash empty — paint one frame immediately.\n      draw(it, 0, staticFrameRef.current);\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(container);\n    resize();\n\n    return () => {\n      ro.disconnect();\n      canvas.removeEventListener(\"contextlost\", onContextLost);\n      internalsRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [dpr, cnt, colorsKey]);\n\n  // Live-sync speed / intensity without rebuilding (and re-bursting) the field.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    if (!it) return;\n    it.speed = spd;\n    it.intensity = intensity;\n    if (staticFrameRef.current) draw(it, 0, true);\n  }, [spd, intensity]);\n\n  // Re-render the frozen frame in place when paused / reduced-motion toggles.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    if (!it || !staticFrame) return;\n    draw(it, 0, true);\n  }, [staticFrame]);\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      data-crucible=\"confetti\"\n      className={cn(\"absolute inset-0 -z-10 overflow-hidden bg-neutral-950\", className)}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"block h-full w-full\" />\n      {/* Soft vignette so the confetti sinks into the frame edges. */}\n      <div\n        className=\"pointer-events-none absolute inset-0\"\n        style={{\n          background:\n            \"radial-gradient(120% 120% at 50% 40%, transparent 58%, rgba(0,0,0,0.5) 100%)\",\n        }}\n      />\n    </div>\n  );\n}\n"
    }
  ]
}