Confetti
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Press the button, or anywhere on the stage, for a burst of confetti.
Code
"use client";
import { motion } from "motion/react";
import { PartyPopper } from "lucide-react";
import { useEffect, useRef } from "react";
import { fitCanvas, Surface } from "../ui";
type Bit = { x: number; y: number; vx: number; vy: number; r: number; vr: number; w: number; h: number; c: string; life: number; shape: number };
const COLORS = ["#2B46B8", "#151515", "#f2b33d", "#e9573f", "#6cc3a0", "#c9c8c3"];
export default function Confetti() {
const canvas = useRef<HTMLCanvasElement>(null);
const api = useRef<{ burst: (x: number, y: number, up?: boolean) => void } | null>(null);
const btn = useRef<HTMLButtonElement>(null);
useEffect(() => {
const cv = canvas.current;
if (!cv) return;
let bits: Bit[] = [];
let raf = 0;
let W = 0;
let H = 0;
let ctx: CanvasRenderingContext2D | null = null;
const size = () => {
const r = cv.getBoundingClientRect();
W = r.width;
H = r.height;
ctx = fitCanvas(cv, W, H);
};
size();
const ro = new ResizeObserver(size);
ro.observe(cv);
const loop = () => {
if (ctx) {
ctx.clearRect(0, 0, W, H);
for (const b of bits) {
b.vy += 0.16;
b.vx *= 0.985;
b.vy *= 0.985;
b.x += b.vx + Math.sin(b.life * 0.15 + b.r) * 0.4;
b.y += b.vy;
b.r += b.vr;
b.life++;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.r);
ctx.scale(1, Math.cos(b.life * 0.18 + b.vr * 20));
ctx.globalAlpha = Math.max(0, Math.min(1, (260 - b.life) / 40));
ctx.fillStyle = b.c;
if (b.shape === 0) ctx.fillRect(-b.w / 2, -b.h / 2, b.w, b.h);
else {
ctx.beginPath();
ctx.arc(0, 0, b.w / 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
bits = bits.filter((b) => b.life < 260 && b.y < H + 40);
}
raf = bits.length ? requestAnimationFrame(loop) : 0;
};
api.current = {
burst: (x, y, up = true) => {
const s = Math.min(W, H) / 300;
for (let i = 0; i < 90; i++) {
const a = up ? -Math.PI / 2 + (Math.random() - 0.5) * 1.6 : Math.random() * Math.PI * 2;
const sp = (4 + Math.random() * 8) * s * (up ? 1.2 : 0.8);
bits.push({
x,
y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp,
r: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
w: (5 + Math.random() * 5) * Math.max(0.7, s),
h: (3 + Math.random() * 4) * Math.max(0.7, s),
c: COLORS[Math.floor(Math.random() * COLORS.length)],
life: Math.random() * 20,
shape: Math.random() < 0.2 ? 1 : 0,
});
}
if (!raf) raf = requestAnimationFrame(loop);
},
};
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
api.current = null;
};
}, []);
const fromButton = () => {
const b = btn.current?.getBoundingClientRect();
const c = canvas.current?.getBoundingClientRect();
if (b && c) api.current?.burst(b.left + b.width / 2 - c.left, b.top - c.top + 4);
};
return (
<Surface>
<div
className="absolute inset-0"
onPointerDown={(e) => {
if (e.target !== e.currentTarget) return;
const c = canvas.current!.getBoundingClientRect();
api.current?.burst(e.clientX - c.left, e.clientY - c.top, false);
}}
/>
<motion.button
ref={btn}
type="button"
onClick={fromButton}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.9, rotate: -3 }}
className="relative z-10 flex items-center gap-[0.5em] rounded-full bg-[#151515] px-[1.4em] py-[0.8em] text-[0.95em] font-medium text-white shadow-[0_10px_24px_-12px_rgba(0,0,0,0.6)]"
>
<PartyPopper className="h-[1.2em] w-[1.2em]" />
Celebrate
</motion.button>
<canvas ref={canvas} className="pointer-events-none absolute inset-0 z-20 h-full w-full" />
<p className="pointer-events-none absolute bottom-[6%] font-mono text-[0.7em] text-[#8a8984]">press the button, or anywhere</p>
</Surface>
);
}
Prompt
Build "Confetti", a button animation. What it does: Press the button, or anywhere on the stage, for a burst of confetti. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using lucide-react, motion. Technique: an HTML canvas, a requestAnimationFrame loop, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).