"use client";
import * as React from "react";
export type ConfettiShape = "mixed" | "square" | "circle" | "strip";
/** Where a burst starts: an element (its centre) or a point in fractions (0–1) of the canvas. */
export type ConfettiOrigin = Element | { x: number; y: number };
export interface ConfettiOptions {
/** Number of pieces in the burst. */
count?: number;
/** Launch direction in degrees: 90 is straight up, 0 is right, 180 is left. */
angle?: number;
/** Cone width in degrees around `angle` (360 = all directions). */
spread?: number;
/** Launch speed in px per frame (at 60fps). */
velocity?: number;
/** Gravity multiplier. */
gravity?: number;
/** Air drag: fraction of speed kept per frame (0.8–0.99). */
drag?: number;
/** Constant sideways push in px per frame²; negative blows left. */
wind?: number;
/** Piece colours (any CSS colour). */
colors?: string[];
/** Piece shape. */
shape?: ConfettiShape;
/** Piece size multiplier. */
size?: number;
/** Burst origin. */
origin?: ConfettiOrigin;
}
export interface ConfettiHandle {
/** Launches a burst; options override the component's props for this burst only. */
fire: (options?: ConfettiOptions) => void;
/** Removes every piece immediately. */
clear: () => void;
}
export interface ConfettiProps
extends ConfettiOptions,
Omit<React.ComponentPropsWithoutRef<"canvas">, "children" | "color"> {
ref?: React.Ref<ConfettiHandle>;
/** Controller from `useConfetti()`; an alternative to `ref`. */
controller?: ConfettiController;
/** Fires a burst whenever this value changes (not on mount). */
trigger?: unknown;
/** "fixed" covers the viewport; "absolute" fills the nearest positioned ancestor. */
position?: "fixed" | "absolute";
/** Text announced politely to screen readers on every burst. */
announce?: string;
/** With prefers-reduced-motion: a brief static scatter of pieces, or nothing at all. */
reducedMotion?: "static" | "none";
}
export const CONFETTI_COLORS = ["#FF7448", "#B6F3D1", "#FFB59B", "#5FD3A0", "#FFD98A", "#F4F1EA"];
const DEFAULTS = {
count: 120,
angle: 90,
spread: 70,
velocity: 32,
gravity: 1,
drag: 0.92,
wind: 0,
shape: "mixed" as ConfettiShape,
size: 1,
};
const MAX_PIECES = 1500;
const STATIC_MS = 1600;
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
function resolveColor(input: string): [number, number, number] {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return [255, 116, 72];
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input;
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? [255, 116, 72] : [r, g, b];
}
/** Front and back (shaded) fill for each colour, so flipping pieces catch the light. */
function palette(colors: string[]) {
return (colors.length ? colors : CONFETTI_COLORS).map((c) => {
const [r, g, b] = resolveColor(c);
return [`rgb(${r}, ${g}, ${b})`, `rgb(${Math.round(r * 0.68)}, ${Math.round(g * 0.68)}, ${Math.round(b * 0.68)})`] as const;
});
}
/* --------------------------------- Engine ---------------------------------- */
interface Piece {
x: number;
y: number;
vx: number;
vy: number;
drag: number;
rot: number;
spin: number;
flip: number;
flipSpeed: number;
wobble: number;
wobbleSpeed: number;
w: number;
h: number;
kind: 0 | 1 | 2; // square, circle, strip
front: string;
back: string;
age: number;
life: number;
gravity: number;
wind: number;
}
type Resolved = Required<Omit<ConfettiOptions, "origin" | "colors">> & { colors: string[]; origin: ConfettiOrigin };
function createEngine(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d");
let pieces: Piece[] = [];
let raf = 0;
let last = 0;
let width = 0;
let height = 0;
let dpr = 1;
let staticTimer = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
};
const originPoint = (origin: ConfettiOrigin) => {
if (origin instanceof Element) {
const r = origin.getBoundingClientRect();
const c = canvas.getBoundingClientRect();
return [r.left + r.width / 2 - c.left, r.top + r.height / 2 - c.top] as const;
}
return [origin.x * width, origin.y * height] as const;
};
const make = (o: Resolved, x: number, y: number, fills: ReturnType<typeof palette>, kindIndex: number): Piece => {
const kind: 0 | 1 | 2 =
o.shape === "square" ? 0 : o.shape === "circle" ? 1 : o.shape === "strip" ? 2 : ((kindIndex % 3) as 0 | 1 | 2);
const s = (6 + Math.random() * 5) * o.size;
const dir = ((o.angle + (Math.random() - 0.5) * o.spread) * Math.PI) / 180;
const speed = o.velocity * (0.5 + Math.random() * 0.5);
const [front, back] = fills[Math.floor(Math.random() * fills.length)];
return {
x,
y,
vx: Math.cos(dir) * speed,
vy: -Math.sin(dir) * speed,
drag: Math.min(0.99, Math.max(0.8, o.drag + (Math.random() - 0.5) * 0.03)),
rot: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.25,
flip: Math.random() * Math.PI * 2,
flipSpeed: 0.08 + Math.random() * 0.14,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: 0.04 + Math.random() * 0.06,
w: kind === 2 ? s * 0.38 : s,
h: kind === 2 ? s * 2.1 : kind === 1 ? s : s * 0.72,
kind,
front,
back,
age: 0,
life: 2600 + Math.random() * 1800,
gravity: 0.42 * o.gravity * (0.85 + Math.random() * 0.3),
wind: o.wind * 0.06,
};
};
const draw = () => {
if (!ctx) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of pieces) {
const fade = p.age > p.life * 0.75 ? Math.max(0, 1 - (p.age - p.life * 0.75) / (p.life * 0.25)) : 1;
const sy = Math.cos(p.flip); // 3D-ish flip: squash on one axis
const cos = Math.cos(p.rot);
const sin = Math.sin(p.rot);
ctx.setTransform(cos * dpr, sin * dpr, -sin * sy * dpr, cos * sy * dpr, p.x * dpr, p.y * dpr);
ctx.globalAlpha = fade;
ctx.fillStyle = sy < 0 ? p.back : p.front;
if (p.kind === 1) {
ctx.beginPath();
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
}
ctx.globalAlpha = 1;
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(48, last ? now - last : 16.7);
last = now;
const f = dt / 16.7;
const next: Piece[] = [];
for (const p of pieces) {
const k = Math.pow(p.drag, f);
p.vx = p.vx * k + p.wind * f;
p.vy = p.vy * k + p.gravity * f;
p.wobble += p.wobbleSpeed * f;
p.x += (p.vx + Math.sin(p.wobble) * 0.9) * f;
p.y += p.vy * f;
p.rot += p.spin * f;
p.flip += p.flipSpeed * f;
p.age += dt;
if (p.age < p.life && p.y < height + 40 && p.x > -80 && p.x < width + 80) next.push(p);
}
pieces = next;
draw();
if (pieces.length) raf = requestAnimationFrame(frame);
else {
last = 0;
clear(); // settled: release the canvas pixels
}
};
const clear = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
window.clearTimeout(staticTimer);
pieces = [];
if (ctx) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
};
const fire = (o: Resolved, still: boolean) => {
resize();
const [x, y] = originPoint(o.origin);
const fills = palette(o.colors);
if (still) {
// Reduced motion: a small static scatter around the origin, cleared after a moment.
clear();
for (let i = 0; i < 18; i++) {
const p = make(o, x, y, fills, i);
const a = Math.random() * Math.PI * 2;
const d = 24 + Math.random() * 70;
p.x += Math.cos(a) * d;
p.y += Math.sin(a) * d * 0.7;
p.flip = Math.random() * 0.8; // mostly face-on
pieces.push(p);
}
draw();
staticTimer = window.setTimeout(clear, STATIC_MS);
return;
}
const n = Math.max(1, Math.min(Math.round(o.count), MAX_PIECES - pieces.length));
for (let i = 0; i < n; i++) pieces.push(make(o, x, y, fills, i));
if (!raf) raf = requestAnimationFrame(frame);
};
return { fire, clear, resize };
}
/* ----------------------------- Reduced motion ------------------------------ */
const reducedMotionQuery = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* -------------------------------- Component -------------------------------- */
/**
* A canvas confetti layer. Fire bursts imperatively through a ref (or the `useConfetti` hook),
* or declaratively by changing `trigger`. Pieces are squares, circles or strips with a fake 3D
* flip, gravity, drag, wobble and wind; the rAF loop only runs while pieces are alive and the
* canvas is cleared as soon as everything settles.
*/
export function Confetti({
ref,
controller,
trigger,
position = "fixed",
announce,
reducedMotion = "static",
count = DEFAULTS.count,
angle = DEFAULTS.angle,
spread = DEFAULTS.spread,
velocity = DEFAULTS.velocity,
gravity = DEFAULTS.gravity,
drag = DEFAULTS.drag,
wind = DEFAULTS.wind,
colors = CONFETTI_COLORS,
shape = DEFAULTS.shape,
size = DEFAULTS.size,
origin,
className = "",
style,
...props
}: ConfettiProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const liveRef = React.useRef<HTMLSpanElement>(null);
const engineRef = React.useRef<ReturnType<typeof createEngine> | null>(null);
const announceTimer = React.useRef(0);
// Latest props for imperative calls, kept outside render.
const optionsRef = React.useRef<Resolved | null>(null);
const settingsRef = React.useRef({ announce, reducedMotion });
React.useEffect(() => {
optionsRef.current = {
count,
angle,
spread,
velocity,
gravity,
drag,
wind,
colors,
shape,
size,
origin: origin ?? { x: 0.5, y: 0.6 },
};
settingsRef.current = { announce, reducedMotion };
});
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const engine = createEngine(canvas);
engineRef.current = engine;
const ro = new ResizeObserver(() => engine.resize());
ro.observe(canvas);
const timer = announceTimer;
return () => {
ro.disconnect();
engine.clear();
window.clearTimeout(timer.current);
engineRef.current = null;
};
}, []);
const fire = React.useCallback((options?: ConfettiOptions) => {
const engine = engineRef.current;
const base = optionsRef.current;
if (!engine || !base) return;
const { announce: text, reducedMotion: rm } = settingsRef.current;
const reduce = reducedMotionQuery();
if (!reduce || rm === "static") {
const merged = { ...base, ...Object.fromEntries(Object.entries(options ?? {}).filter(([, v]) => v !== undefined)) } as Resolved;
engine.fire(merged, reduce);
}
const live = liveRef.current;
if (text && live) {
// Clear first so repeating the same message is announced again.
live.textContent = "";
window.clearTimeout(announceTimer.current);
announceTimer.current = window.setTimeout(() => {
live.textContent = text;
}, 60);
}
}, []);
const clear = React.useCallback(() => engineRef.current?.clear(), []);
React.useImperativeHandle(ref, () => ({ fire, clear }), [fire, clear]);
React.useEffect(() => {
if (!controller) return;
controller.attach({ fire, clear });
return () => controller.attach(null);
}, [controller, fire, clear]);
// Declarative trigger: fire when the value changes after mount.
const lastTrigger = React.useRef(trigger);
React.useEffect(() => {
if (Object.is(lastTrigger.current, trigger)) return;
lastTrigger.current = trigger;
fire();
}, [trigger, fire]);
return (
<>
<canvas
ref={canvasRef}
aria-hidden="true"
className={`pointer-events-none inset-0 z-50 block h-full w-full ${position === "fixed" ? "fixed" : "absolute"} ${className}`}
style={style}
{...props}
/>
<span ref={liveRef} role="status" aria-live="polite" className="sr-only" />
</>
);
}
export interface ConfettiController {
/** Called by `<Confetti controller={…} />` to connect itself; you rarely need it directly. */
attach: (handle: ConfettiHandle | null) => void;
fire: (options?: ConfettiOptions) => void;
clear: () => void;
}
function createController(): ConfettiController {
let handle: ConfettiHandle | null = null;
return {
attach: (next) => {
handle = next;
},
fire: (options) => handle?.fire(options),
clear: () => handle?.clear(),
};
}
/**
* Convenience hook: `const confetti = useConfetti()`, render `<Confetti controller={confetti} />`,
* then call `confetti.fire({ origin: event.currentTarget })` from any handler. The returned
* controller is stable for the component's lifetime.
*/
export function useConfetti(): ConfettiController {
const [controller] = React.useState(createController);
return controller;
}