Confetti
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).
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/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, spd, colorsKey, 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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/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 all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.