Confetti Button
Fires a confetti burst from the button on click — no canvas, no library.
components/ui/confetti-button.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { PartyPopper } from "lucide-react";
import { useState, type ReactNode } from "react";
const COLORS = ["#2563eb", "#7c3aed", "#db2777", "#f59e0b", "#10b981", "#06b6d4"];
type Piece = { id: number; x: number; y: number; rotate: number; color: string; round: boolean };
function burst(seed: number, count: number): Piece[] {
return Array.from({ length: count }, (_, i) => {
const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5;
const distance = 60 + Math.random() * 90;
return { id: seed * 1000 + i, x: Math.cos(angle) * distance, y: Math.sin(angle) * distance - 40, rotate: Math.random() * 540 - 270, color: COLORS[i % COLORS.length], round: i % 3 === 0 };
});
}
/** Button that fires a confetti burst from its centre on click. No canvas, no library. */
export function ConfettiButton({ children, pieces = 26, onClick }: { children: ReactNode; pieces?: number; onClick?: () => void }) {
const [bursts, setBursts] = useState<{ id: number; pieces: Piece[] }[]>([]);
function fire() {
onClick?.();
if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = Date.now();
setBursts((b) => [...b, { id, pieces: burst(id, pieces) }]);
setTimeout(() => setBursts((b) => b.filter((x) => x.id !== id)), 1400);
}
return (
<span className="relative inline-block">
<motion.button whileTap={{ scale: 0.94 }} onClick={fire} className="relative z-10 inline-flex items-center gap-2 rounded-full bg-zinc-900 px-6 py-3 text-sm font-medium text-white shadow-lg hover:bg-zinc-800">
{children}
</motion.button>
<span aria-hidden className="pointer-events-none absolute left-1/2 top-1/2">
<AnimatePresence>
{bursts.flatMap((b) =>
b.pieces.map((p) => (
<motion.i
key={p.id}
className="absolute block"
style={{ width: p.round ? 8 : 6, height: p.round ? 8 : 12, borderRadius: p.round ? 999 : 2, background: p.color }}
initial={{ x: 0, y: 0, opacity: 1, rotate: 0, scale: 0.6 }}
animate={{ x: p.x, y: [0, p.y, p.y + 120], opacity: [1, 1, 0], rotate: p.rotate, scale: 1 }}
transition={{ duration: 1.2, ease: [0.2, 0.8, 0.3, 1], times: [0, 0.45, 1] }}
/>
)),
)}
</AnimatePresence>
</span>
</span>
);
}
export default function Demo() {
return (
<div className="grid h-64 place-items-center">
<ConfettiButton>
<PartyPopper className="size-4" /> Celebrate
</ConfettiButton>
</div>
);
}
Edit with AIPro
Describe a change to Confetti Button and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/confetti-button.tsxcomponents/ui/confetti-button.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { PartyPopper } from "lucide-react"; import { useState, type ReactNode } from "react"; const COLORS = ["#2563eb", "#7c3aed", "#db2777", "#f59e0b", "#10b981", "#06b6d4"]; type Piece = { id: number; x: number; y: number; rotate: number; color: string; round: boolean }; function burst(seed: number, count: number): Piece[] { return Array.from({ length: count }, (_, i) => { const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5; const distance = 60 + Math.random() * 90; return { id: seed * 1000 + i, x: Math.cos(angle) * distance, y: Math.sin(angle) * distance - 40, rotate: Math.random() * 540 - 270, color: COLORS[i % COLORS.length], round: i % 3 === 0 }; }); } /** Button that fires a confetti burst from its centre on click. No canvas, no library. */ export function ConfettiButton({ children, pieces = 26, onClick }: { children: ReactNode; pieces?: number; onClick?: () => void }) { const [bursts, setBursts] = useState<{ id: number; pieces: Piece[] }[]>([]); function fire() { onClick?.(); if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const id = Date.now(); setBursts((b) => [...b, { id, pieces: burst(id, pieces) }]); setTimeout(() => setBursts((b) => b.filter((x) => x.id !== id)), 1400); } return ( <span className="relative inline-block"> <motion.button whileTap={{ scale: 0.94 }} onClick={fire} className="relative z-10 inline-flex items-center gap-2 rounded-full bg-zinc-900 px-6 py-3 text-sm font-medium text-white shadow-lg hover:bg-zinc-800"> {children} </motion.button> <span aria-hidden className="pointer-events-none absolute left-1/2 top-1/2"> <AnimatePresence> {bursts.flatMap((b) => b.pieces.map((p) => ( <motion.i key={p.id} className="absolute block" style={{ width: p.round ? 8 : 6, height: p.round ? 8 : 12, borderRadius: p.round ? 999 : 2, background: p.color }} initial={{ x: 0, y: 0, opacity: 1, rotate: 0, scale: 0.6 }} animate={{ x: p.x, y: [0, p.y, p.y + 120], opacity: [1, 1, 0], rotate: p.rotate, scale: 1 }} transition={{ duration: 1.2, ease: [0.2, 0.8, 0.3, 1], times: [0, 0.45, 1] }} /> )), )} </AnimatePresence> </span> </span> ); } export default function Demo() { return ( <div className="grid h-64 place-items-center"> <ConfettiButton> <PartyPopper className="size-4" /> Celebrate </ConfettiButton> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.