# Confetti — Animated Background for React

> 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).

- Page: https://crucibleui.com/components/backgrounds/confetti
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/confetti
```

Registry JSON: https://crucibleui.com/r/confetti.json

Import from `@/components/crucible/confetti`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`ConfettiProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors?` | `string[]` | `["#ff7eb6", "#ffd166", "#68e0a8", "#6bb8ff", "#b892ff", "#ff9a6b"]` | Confetti palette — pieces are colored evenly across this list. Defaults to a tasteful festive spread (pink, gold, mint, blue, violet, peach) on near-black. Pass a tighter set (e.g. two brand tones) for on-brand celebration, or a single-hue array for monochrome fall. |
| `speed?` | `number` | `1` | Fall / flutter / burst-cadence multiplier. 1 = default, 2 = twice as lively. |
| `count?` | `number` | `1` | Piece-count multiplier over the area-based default, 0.2–3. |
| `intensity?` | `number` | `1` | Overall piece opacity and sheen, 0–2. |
| `dpr?` | `number` | `2` | devicePixelRatio ceiling. Clamped 1–2. |
| `paused?` | `boolean` | `false` | Freeze at a static, settled scatter of confetti. |

Also accepts: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Confetti } from "@/components/crucible/confetti";
import { DemoScene } from "@/components/registry/demo-scene";

export default function ConfettiDemo() {
  return (
    <DemoScene
      background={<Confetti />}
      headline="You're all set — nice work"
      subhead="An opening burst that settles into a gentle, endless drift."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`confetti.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

export interface ConfettiProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * Confetti palette — pieces are colored evenly across this list. Defaults to a
   * tasteful festive spread (pink, gold, mint, blue, violet, peach) on near-black.
   * Pass a tighter set (e.g. two brand tones) for on-brand celebration, or a
   * single-hue array for monochrome fall.
   * @default ["#ff7eb6", "#ffd166", "#68e0a8", "#6bb8ff", "#b892ff", "#ff9a6b"]
   */
  colors?: string[];
  /** Fall / flutter / burst-cadence multiplier. 1 = default, 2 = twice as lively. @default 1 */
  speed?: number;
  /** Piece-count multiplier over the area-based default, 0.2–3. @default 1 */
  count?: number;
  /** Overall piece opacity and sheen, 0–2. @default 1 */
  intensity?: number;
  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
  dpr?: number;
  /** Freeze at a static, settled scatter of confetti. @default false */
  paused?: boolean;
}

const DEFAULT_COLORS: string[] = [
  "#ff7eb6",
  "#ffd166",
  "#68e0a8",
  "#6bb8ff",
  "#b892ff",
  "#ff9a6b",
];

/** Deterministic mulberry32 — seeded so the scatter is identical SSR intent ↔ CSR. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const SEED = 0x0c0ffee1;

function hexToRgb(hex: string): [number, number, number] {
  let h = hex.replace("#", "").trim();
  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
  const int = parseInt(h, 16);
  if (Number.isNaN(int) || h.length !== 6) return [255, 126, 182];
  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}

// --- physics constants (CSS px, seconds) -----------------------------------
const GRAVITY = 360; // downward accel
const DRAG = 1.6; // velocity damping/s → sets terminal fall speed (~GRAVITY/DRAG)
const MARGIN = 44; // offscreen spawn/recycle margin
const STREAMER_RATIO = 0.22; // fraction of pieces that are long thin ribbons
const BURST_FRACTION = 0.3; // fraction of pieces reserved for upward burst puffs
const PARKED_Y = 1e6; // sentinel: burst piece resting off-stage awaiting launch

interface Piece {
  x: number;
  y: number;
  vx: number;
  vy: number;
  rot: number; // spin angle (rad)
  spin: number; // rad/s
  wob: number; // flutter/tumble phase (rad)
  wobFreq: number; // rad/s
  sway: number; // horizontal flutter amplitude, px/s
  w: number;
  h: number;
  colorIdx: number;
  alpha: number;
  streamer: boolean;
  isBurst: boolean;
  parked: boolean; // burst piece resting off-stage until next puff
  // Frozen settled pose — the reduced-motion / paused frame.
  staticX: number;
  staticY: number;
  staticRot: number;
  staticWob: number;
}

interface Internals {
  ctx: CanvasRenderingContext2D;
  width: number;
  height: number;
  pieces: Piece[];
  palette: [number, number, number][];
  rng: () => number; // persistent stream driving bursts
  speed: number;
  intensity: number;
  clock: number; // sim-seconds accumulator (scaled by speed)
  nextBurst: number; // clock time of the next puff
  burstCursor: number; // rotating index into the burst pool
  burstCount: number; // pieces launched per puff
  fallCount: number; // number of ambient falling pieces
  bg: string;
}

/** Fresh ambient faller descending from just above the top edge. */
function spawnFall(p: Piece, w: number, rng: () => number) {
  p.x = rng() * w;
  p.y = -MARGIN - rng() * MARGIN * 2;
  p.vx = (rng() - 0.5) * 40;
  p.vy = 30 + rng() * 40;
  p.spin = (rng() - 0.5) * 10;
  p.wobFreq = 4 + rng() * 5;
  p.parked = false;
}

/** Launch a parked burst piece upward from an off-bottom origin as a puff. */
function launchBurst(p: Piece, originX: number, height: number, rng: () => number) {
  // Fan of upward velocities from a popper just below the frame.
  const spread = (rng() - 0.5) * 0.95; // angle off vertical, rad (~±27°)
  const speed = 440 + rng() * 300;
  p.x = originX + (rng() - 0.5) * 24;
  p.y = height + MARGIN + rng() * 12;
  p.vx = Math.sin(spread) * speed + (rng() - 0.5) * 90;
  p.vy = -Math.cos(spread) * speed;
  p.spin = (rng() - 0.5) * 14;
  p.wobFreq = 5 + rng() * 6;
  p.parked = false;
}

/** Fire one upward puff — relaunch a batch of parked burst pieces from a seeded origin. */
function fireBurst(it: Internals) {
  const originX = it.width * (0.18 + it.rng() * 0.64);
  let launched = 0;
  for (let scan = 0; scan < it.pieces.length && launched < it.burstCount; scan++) {
    const idx = it.fallCount + (it.burstCursor % (it.pieces.length - it.fallCount || 1));
    it.burstCursor++;
    const p = it.pieces[idx];
    if (!p || !p.isBurst || !p.parked) continue;
    launchBurst(p, originX, it.height, it.rng);
    launched++;
  }
}

function buildPieces(
  w: number,
  h: number,
  total: number,
  palLen: number
): { pieces: Piece[]; fallCount: number } {
  const rng = mulberry32(SEED ^ 0x9e3779b9);
  const burstN = Math.round(total * BURST_FRACTION);
  const fallCount = total - burstN;
  const pieces: Piece[] = [];

  for (let i = 0; i < total; i++) {
    const isBurst = i >= fallCount;
    const streamer = rng() < STREAMER_RATIO;
    const pw = streamer ? 3 + rng() * 2 : 6 + rng() * 5;
    const ph = streamer ? 16 + rng() * 14 : 8 + rng() * 6;
    const p: Piece = {
      x: rng() * w,
      // Ambient fallers pre-scatter across the whole frame so it's never empty;
      // burst pieces park off-stage until their first puff.
      y: isBurst ? PARKED_Y : rng() * h,
      vx: (rng() - 0.5) * 40,
      vy: 30 + rng() * 60,
      rot: rng() * Math.PI * 2,
      spin: (rng() - 0.5) * 10,
      wob: rng() * Math.PI * 2,
      wobFreq: 4 + rng() * (streamer ? 3 : 5),
      sway: (streamer ? 34 : 20) * (0.7 + rng() * 0.6),
      w: pw,
      h: ph,
      colorIdx: i % palLen,
      alpha: 0.86 + rng() * 0.14,
      streamer,
      isBurst,
      parked: isBurst,
      // Settled scatter, biased toward the bottom (sqrt pushes mass downward).
      staticX: rng() * w,
      staticY: h * (0.06 + 0.92 * Math.sqrt(rng())),
      staticRot: rng() * Math.PI * 2,
      staticWob: rng() * Math.PI * 2,
    };
    pieces.push(p);
  }
  return { pieces, fallCount };
}

/** Draw one piece as a tumbling foreshortened chip; backface reads darker for depth. */
function drawPiece(
  it: Internals,
  p: Piece,
  x: number,
  y: number,
  rot: number,
  wob: number
) {
  const { ctx } = it;
  const rgb = it.palette[p.colorIdx % it.palette.length];
  const fscale = Math.cos(wob); // tumble: +1 face-on, 0 edge-on, -1 backface
  const sy = 0.26 + 0.74 * Math.abs(fscale);
  const back = fscale < 0;
  const shade = back ? 0.5 : 1; // the reverse of the chip is in shadow
  const a = Math.min(p.alpha * it.intensity, 1);
  if (a <= 0.01) return;

  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(rot);
  ctx.scale(1, sy);
  ctx.fillStyle = `rgba(${(rgb[0] * shade) | 0}, ${(rgb[1] * shade) | 0}, ${(rgb[2] * shade) | 0}, ${a})`;
  ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
  // Front-face sheen — a bright sliver along the leading edge.
  if (!back) {
    ctx.fillStyle = `rgba(255,255,255,${Math.min(a * 0.16, 1)})`;
    ctx.fillRect(-p.w / 2, -p.h / 2, p.w, Math.max(p.h * 0.28, 1));
  }
  ctx.restore();
}

/** One frame. `useStatic` freezes every piece at its settled pose. */
function draw(it: Internals, dt: number, useStatic: boolean) {
  const { ctx, width, height } = it;

  ctx.globalCompositeOperation = "source-over";
  ctx.fillStyle = it.bg;
  ctx.fillRect(0, 0, width, height);

  if (useStatic) {
    for (let i = 0; i < it.pieces.length; i++) {
      const p = it.pieces[i];
      drawPiece(it, p, p.staticX, p.staticY, p.staticRot, p.staticWob);
    }
    return;
  }

  const dts = dt * it.speed;

  // ------------------------------------------------------------ burst cadence --
  it.clock += dts;
  if (it.clock >= it.nextBurst) {
    fireBurst(it);
    it.nextBurst = it.clock + 2.6 + it.rng() * 2.2;
  }

  const damp = Math.exp(-DRAG * dts);

  for (let i = 0; i < it.pieces.length; i++) {
    const p = it.pieces[i];
    if (p.parked) continue; // burst piece resting off-stage

    p.vy = (p.vy + GRAVITY * dts) * damp;
    p.vx *= damp;
    p.wob += p.wobFreq * dts;
    p.rot += p.spin * dts;
    p.x += p.vx * dts + Math.sin(p.wob) * p.sway * dts;
    p.y += p.vy * dts;

    // Horizontal wrap so drift never leaves a bare edge.
    if (p.x < -MARGIN) p.x += width + MARGIN * 2;
    else if (p.x > width + MARGIN) p.x -= width + MARGIN * 2;

    // Recycle once fully below the frame.
    if (p.y > height + MARGIN) {
      if (p.isBurst) {
        p.parked = true;
        p.y = PARKED_Y;
      } else {
        spawnFall(p, width, it.rng);
      }
      continue;
    }

    drawPiece(it, p, p.x, p.y, p.rot, p.wob);
  }
}

/** Neutral near-black bed — festive chips read cleanest on a quiet ground. */
const BG = "rgb(9, 9, 11)";

/**
 * Confetti — a celebration background. Seeded rectangular chips and thin
 * streamers fall under gravity with air-drag terminal speed, each one spinning
 * and tumbling on its own axis (foreshortening flip with a shadowed backface)
 * while it flutters side to side. It opens with an upward burst on mount and
 * keeps firing occasional puffs from off the bottom edge, so the field pops and
 * then settles into a gentle, endless drift. Festive-but-tasteful density by
 * default; retint via `colors`. Canvas 2D: seeded and SSR-safe, paused
 * offscreen and on hidden tabs, reduced-motion-safe (a static settled scatter).
 * Drop it inside any `relative` container.
 */
export function Confetti({
  colors = DEFAULT_COLORS,
  speed = 1,
  count = 1,
  intensity = 1,
  dpr = 2,
  paused = false,
  className,
  ...props
}: ConfettiProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const internalsRef = React.useRef<Internals | null>(null);
  const reducedMotion = useReducedMotion();
  const staticFrame = paused || reducedMotion;
  const staticFrameRef = React.useRef(staticFrame);
  staticFrameRef.current = staticFrame;
  const lastElapsedRef = React.useRef(0);

  const cnt = Math.min(Math.max(count, 0.2), 3);
  const spd = Math.min(Math.max(speed, 0.1), 4);
  const colorsKey = colors.join(",");

  const tick = React.useCallback((elapsed: number) => {
    const it = internalsRef.current;
    if (!it) return;
    let dt = elapsed - lastElapsedRef.current;
    lastElapsedRef.current = elapsed;
    if (dt > 0.1) dt = 0.1; // clamp tab-restore jumps
    if (dt < 0) dt = 0;
    draw(it, dt, staticFrameRef.current);
  }, []);

  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const container = containerRef.current;
    if (!canvas || !container) return;

    const ctx = canvas.getContext("2d", { alpha: false });
    if (!ctx) return;

    const effectiveDpr = Math.min(
      typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
      Math.min(Math.max(dpr, 1), 2)
    );

    const source = colors.length ? colors : DEFAULT_COLORS;
    const palette = source.map(hexToRgb);

    const it: Internals = {
      ctx,
      width: 0,
      height: 0,
      pieces: [],
      palette,
      rng: mulberry32(SEED ^ 0x85ebca6b),
      speed: spd,
      intensity,
      clock: 0,
      nextBurst: 0, // fire the opening burst on the first animated frame
      burstCursor: 0,
      burstCount: 0,
      fallCount: 0,
      bg: BG,
    };
    internalsRef.current = it;

    const onContextLost = (e: Event) => e.preventDefault();
    canvas.addEventListener("contextlost", onContextLost);

    const resize = () => {
      const w = Math.max(container.clientWidth, 1);
      const h = Math.max(container.clientHeight, 1);
      canvas.width = Math.round(w * effectiveDpr);
      canvas.height = Math.round(h * effectiveDpr);
      canvas.style.width = "100%";
      canvas.style.height = "100%";
      ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);

      const area = w * h;
      const base = Math.min(190, Math.max(34, Math.round((area / 8200) * cnt)));
      const { pieces, fallCount } = buildPieces(w, h, base, palette.length);

      it.width = w;
      it.height = h;
      it.pieces = pieces;
      it.fallCount = fallCount;
      it.burstCount = Math.max(6, Math.round((base - fallCount) * 0.42));
      it.clock = 0;
      it.nextBurst = 0;
      it.burstCursor = 0;

      // Never flash empty — paint one frame immediately.
      draw(it, 0, staticFrameRef.current);
    };

    const ro = new ResizeObserver(resize);
    ro.observe(container);
    resize();

    return () => {
      ro.disconnect();
      canvas.removeEventListener("contextlost", onContextLost);
      internalsRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dpr, cnt, colorsKey]);

  // Live-sync speed / intensity without rebuilding (and re-bursting) the field.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it) return;
    it.speed = spd;
    it.intensity = intensity;
    if (staticFrameRef.current) draw(it, 0, true);
  }, [spd, intensity]);

  // Re-render the frozen frame in place when paused / reduced-motion toggles.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it || !staticFrame) return;
    draw(it, 0, true);
  }, [staticFrame]);

  return (
    <div
      ref={containerRef}
      aria-hidden
      data-crucible="confetti"
      className={cn("absolute inset-0 -z-10 overflow-hidden bg-neutral-950", className)}
      {...props}
    >
      <canvas ref={canvasRef} className="block h-full w-full" />
      {/* Soft vignette so the confetti sinks into the frame edges. */}
      <div
        className="pointer-events-none absolute inset-0"
        style={{
          background:
            "radial-gradient(120% 120% at 50% 40%, transparent 58%, rgba(0,0,0,0.5) 100%)",
        }}
      />
    </div>
  );
}
```
