Confetti Burst
Physics-based confetti explosion triggered imperatively via ref from any button
confetticanvascelebrationphysicsfeedback
Preview
Source Code
"use client";
import {
forwardRef,
useImperativeHandle,
useRef,
useEffect,
useCallback,
} from "react";
import { createPortal } from "react-dom";
export interface ConfettiBurstRef {
fire: (options?: FireOptions) => void;
}
interface FireOptions {
/** Origin in element-relative 0..1 coords. Default: {x:0.5,y:0.5} */
origin?: { x: number; y: number };
/** Particle count. Default: 80 */
count?: number;
/** Cone spread in degrees. Default: 110 */
spread?: number;
/** Initial speed (px/frame). Default: 9 */
velocity?: number;
/** Gravity per frame (px/frame^2). Default: 0.55 */
gravity?: number;
/** Color palette. Default: brand colors */
colors?: string[];
/** Shape variants. Default: ['rectangle','square','circle'] */
shapes?: Array<"circle" | "square" | "rectangle" | "star">;
/** Particle lifetime in ms. Default: 2200 */
lifetime?: number;
}
interface ConfettiBurstProps {
/** Render as full-page overlay via portal. Default: false */
fullscreen?: boolean;
className?: string;
}
type Shape = "circle" | "square" | "rectangle" | "star";
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
rotation: number;
vRotation: number;
color: string;
shape: Shape;
w: number;
h: number;
wobble: number;
wobbleSpeed: number;
wobbleAmp: number;
gravity: number;
life: number;
lifetime: number;
}
const DEFAULT_COLORS = ["#f59e0b", "#ec4899", "#8b5cf6", "#10b981", "#06b6d4"];
function drawStar(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
r: number
) {
const spikes = 5;
const innerR = r * 0.45;
let angle = -Math.PI / 2;
const step = (Math.PI * 2) / spikes;
ctx.beginPath();
for (let i = 0; i < spikes; i++) {
ctx.lineTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
angle += step / 2;
ctx.lineTo(cx + Math.cos(angle) * innerR, cy + Math.sin(angle) * innerR);
angle += step / 2;
}
ctx.closePath();
}
const ConfettiBurst = forwardRef<ConfettiBurstRef, ConfettiBurstProps>(
function ConfettiBurst({ fullscreen = false, className = "" }, ref) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const rafRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const runningRef = useRef(false);
const getCanvas = useCallback(() => canvasRef.current, []);
const tickRef = useRef<(now: number) => void>(() => {});
const tick = useCallback((now: number) => {
const canvas = getCanvas();
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dt = lastTimeRef.current ? now - lastTimeRef.current : 16;
lastTimeRef.current = now;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.width / dpr;
const cssH = canvas.height / dpr;
ctx.clearRect(0, 0, canvas.width, canvas.height);
particlesRef.current = particlesRef.current.filter((p) => p.life < 1);
for (const p of particlesRef.current) {
// Integrate physics
p.vy += p.gravity;
p.vx *= 0.995;
p.vy *= 0.995;
p.wobble += p.wobbleSpeed;
p.x += p.vx + Math.sin(p.wobble) * p.wobbleAmp;
p.y += p.vy;
p.rotation += p.vRotation;
p.life += dt / p.lifetime;
// Hold full alpha until last 30% of life, then fade
const alpha =
p.life < 0.7 ? 1 : Math.max(0, 1 - (p.life - 0.7) / 0.3);
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.save();
ctx.translate(p.x * dpr, p.y * dpr);
ctx.rotate(p.rotation);
// Flat paper flip: scale Y based on rotation to fake 3D tumble
const flip = Math.abs(Math.cos(p.wobble * 0.8));
ctx.scale(1, 0.3 + flip * 0.7);
if (p.shape === "circle") {
ctx.beginPath();
ctx.arc(0, 0, (p.w * dpr) / 2, 0, Math.PI * 2);
ctx.fill();
} else if (p.shape === "square" || p.shape === "rectangle") {
const w = p.w * dpr;
const h = p.h * dpr;
ctx.fillRect(-w / 2, -h / 2, w, h);
} else {
drawStar(ctx, 0, 0, (p.w * dpr) / 2);
ctx.fill();
}
ctx.restore();
// Kill when far off-screen in any direction
if (
p.x < -cssW * 0.5 ||
p.x > cssW * 1.5 ||
p.y > cssH * 1.5 ||
p.y < -cssH * 0.8
) {
p.life = 1;
}
}
ctx.globalAlpha = 1;
if (particlesRef.current.length > 0) {
rafRef.current = requestAnimationFrame((t) => tickRef.current(t));
} else {
runningRef.current = false;
lastTimeRef.current = 0;
}
}, [getCanvas]);
useEffect(() => {
tickRef.current = tick;
}, [tick]);
const fire = useCallback(
(options: FireOptions = {}) => {
const canvas = getCanvas();
if (!canvas) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const ox = (options.origin?.x ?? 0.5) * (canvas.width / dpr);
const oy = (options.origin?.y ?? 0.5) * (canvas.height / dpr);
const grad = ctx.createRadialGradient(
ox * dpr,
oy * dpr,
0,
ox * dpr,
oy * dpr,
80 * dpr
);
grad.addColorStop(0, "rgba(245,158,11,0.7)");
grad.addColorStop(1, "rgba(245,158,11,0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(ox * dpr, oy * dpr, 80 * dpr, 0, Math.PI * 2);
ctx.fill();
setTimeout(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}, 400);
return;
}
const {
origin = { x: 0.5, y: 0.5 },
count = 80,
spread = 110,
velocity = 9,
gravity = 0.55,
colors = DEFAULT_COLORS,
shapes = ["rectangle", "square", "circle"] as Shape[],
lifetime = 2200,
} = options;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.width / dpr;
const cssH = canvas.height / dpr;
const ox = origin.x * cssW;
const oy = origin.y * cssH;
// Burst cone centered upward
const centerAngle = -Math.PI / 2;
const halfSpread = ((spread * Math.PI) / 180) * 0.5;
const newParticles: Particle[] = Array.from({ length: count }, () => {
const angle =
centerAngle + (Math.random() - 0.5) * 2 * halfSpread;
// Varied speed for depth — some particles are "closer" and fly further
const speed = velocity * (0.6 + Math.random() * 0.8);
const shape = shapes[Math.floor(Math.random() * shapes.length)];
const w =
shape === "rectangle"
? 7 + Math.random() * 5
: 4 + Math.random() * 4;
const h =
shape === "rectangle" ? 3 + Math.random() * 2 : w;
return {
x: ox + (Math.random() - 0.5) * 8,
y: oy + (Math.random() - 0.5) * 8,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rotation: Math.random() * Math.PI * 2,
vRotation: (Math.random() - 0.5) * 0.35,
color: colors[Math.floor(Math.random() * colors.length)],
shape,
w,
h,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: 0.1 + Math.random() * 0.1,
wobbleAmp: 0.6 + Math.random() * 0.8,
gravity,
life: 0,
lifetime,
};
});
particlesRef.current.push(...newParticles);
if (!runningRef.current) {
runningRef.current = true;
lastTimeRef.current = 0;
rafRef.current = requestAnimationFrame((t) => tickRef.current(t));
}
},
[getCanvas]
);
useImperativeHandle(ref, () => ({ fire }), [fire]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
let cssW: number;
let cssH: number;
if (fullscreen) {
cssW = window.innerWidth;
cssH = window.innerHeight;
} else {
const parent = canvas.parentElement;
if (!parent) return;
cssW = parent.clientWidth;
cssH = parent.clientHeight;
}
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
};
resize();
const ro = new ResizeObserver(resize);
if (fullscreen) {
window.addEventListener("resize", resize);
} else {
const parent = canvas.parentElement;
if (parent) ro.observe(parent);
}
return () => {
cancelAnimationFrame(rafRef.current);
ro.disconnect();
window.removeEventListener("resize", resize);
};
}, [fullscreen]);
const canvasEl = (
<canvas
ref={canvasRef}
aria-hidden="true"
className={
fullscreen
? `pointer-events-none ${className}`
: `absolute inset-0 pointer-events-none ${className}`
}
style={
fullscreen
? { position: "fixed", inset: 0, zIndex: 9999 }
: undefined
}
/>
);
if (fullscreen && typeof window !== "undefined") {
return createPortal(canvasEl, document.body);
}
return canvasEl;
}
);
export default ConfettiBurst;