Baktiyar Ablaikhan

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).