Confetti
Dependency-free confetti burst, fired from anywhere.
1.4 KB gzippedZero dependenciesReduced-motion safeBase UI & Radix compatible
Installation
npx shadcn@latest add https://velora.colorlib.com/r/confetti.jsonSource
"use client";
import { cn } from "@/lib/utils";
interface BurstOptions {
/** Origin in viewport px; defaults to screen center */
x?: number;
y?: number;
count?: number;
colors?: string[];
}
const defaultColors = ["#8b5cf6", "#d946ef", "#22d3ee", "#fbbf24", "#34d399"];
/**
* Fire a confetti burst — no canvas library, ~2s, cleans up after itself.
* No-op when the user prefers reduced motion.
*/
export function confettiBurst({
x = window.innerWidth / 2,
y = window.innerHeight / 2,
count = 90,
colors = defaultColors,
}: BurstOptions = {}) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const canvas = document.createElement("canvas");
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.cssText =
"position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:9999";
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const particles = Array.from({ length: count }, () => {
const angle = Math.random() * Math.PI * 2;
const velocity = 6 + Math.random() * 8;
return {
x,
y,
vx: Math.cos(angle) * velocity,
vy: Math.sin(angle) * velocity - 4,
size: 4 + Math.random() * 5,
rotation: Math.random() * 360,
spin: (Math.random() - 0.5) * 20,
color: colors[Math.floor(Math.random() * colors.length)],
life: 1,
};
});
const start = performance.now();
const tick = (now: number) => {
const elapsed = (now - start) / 1000;
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
let alive = false;
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.25;
p.vx *= 0.99;
p.rotation += p.spin;
p.life = Math.max(0, 1 - elapsed / 1.8);
if (p.life <= 0) continue;
alive = true;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate((p.rotation * Math.PI) / 180);
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
ctx.restore();
}
if (alive) {
requestAnimationFrame(tick);
} else {
canvas.remove();
}
};
requestAnimationFrame(tick);
}
interface ConfettiButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
}
export function ConfettiButton({
children,
className,
onClick,
...props
}: ConfettiButtonProps) {
return (
<button
data-slot="confetti-button"
className={cn(
"inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full border bg-card px-6 text-sm font-medium transition-transform hover:scale-[1.03] active:scale-[0.97]",
className
)}
onClick={(e) => {
confettiBurst({ x: e.clientX, y: e.clientY });
onClick?.(e);
}}
{...props}
>
{children}
</button>
);
}