← Back
Canvas

Fireworks

Click anywhere to launch a particle burst with gravity, spread, and color trails.

click anywhere · canvas · gravity · particle trails

Fireworks.tsx124 lines
"use client";

import { useEffect, useRef } from "react";

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  color: string;
  size: number;
  trail: { x: number; y: number }[];
}

export default function Fireworks() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const particlesRef = useRef<Particle[]>([]);
  const rafRef = useRef<number>(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const resize = () => {
      canvas.width = canvas.offsetWidth;
      canvas.height = canvas.offsetHeight;
    };
    resize();

    const COLORS = [
      "#ff6b6b", "#ffd93d", "#6bcb77", "#4d96ff",
      "#c77dff", "#ff9f1c", "#ff4ff8", "#00f5d4",
    ];

    const burst = (x: number, y: number) => {
      const color = COLORS[Math.floor(Math.random() * COLORS.length)];
      const count = 80 + Math.floor(Math.random() * 40);
      for (let i = 0; i < count; i++) {
        const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.3;
        const speed = Math.random() * 5 + 2;
        particlesRef.current.push({
          x,
          y,
          vx: Math.cos(angle) * speed,
          vy: Math.sin(angle) * speed,
          life: 1,
          color,
          size: Math.random() * 2.5 + 1,
          trail: [],
        });
      }
    };

    const draw = () => {
      ctx.fillStyle = "rgba(0,0,0,0.18)";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      particlesRef.current = particlesRef.current.filter((p) => p.life > 0);

      for (const p of particlesRef.current) {
        p.trail.push({ x: p.x, y: p.y });
        if (p.trail.length > 5) p.trail.shift();

        p.x += p.vx;
        p.y += p.vy;
        p.vy += 0.07;
        p.vx *= 0.99;
        p.life = Math.max(0, p.life - 0.017);

        // trail
        for (let t = 0; t < p.trail.length - 1; t++) {
          ctx.beginPath();
          ctx.strokeStyle =
            p.color + Math.floor((t / p.trail.length) * p.life * 80).toString(16).padStart(2, "0");
          ctx.lineWidth = p.size * (t / p.trail.length);
          ctx.moveTo(p.trail[t].x, p.trail[t].y);
          ctx.lineTo(p.trail[t + 1].x, p.trail[t + 1].y);
          ctx.stroke();
        }

        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
        ctx.fillStyle = p.color + Math.floor(p.life * 255).toString(16).padStart(2, "0");
        ctx.fill();
      }

      rafRef.current = requestAnimationFrame(draw);
    };

    const onClick = (e: MouseEvent) => {
      const rect = canvas.getBoundingClientRect();
      burst(e.clientX - rect.left, e.clientY - rect.top);
    };

    // auto launch a burst on mount for immediate wow-factor
    setTimeout(() => burst(canvas.width / 2, canvas.height / 2), 200);
    setTimeout(() => burst(canvas.width * 0.3, canvas.height * 0.4), 600);
    setTimeout(() => burst(canvas.width * 0.7, canvas.height * 0.35), 900);

    canvas.addEventListener("click", onClick);
    draw();

    return () => {
      cancelAnimationFrame(rafRef.current);
      canvas.removeEventListener("click", onClick);
    };
  }, []);

  return (
    <div className="relative w-full">
      <canvas
        ref={canvasRef}
        className="w-full h-[420px] rounded-xl bg-zinc-950 cursor-crosshair block"
      />
      <p className="absolute bottom-4 left-0 right-0 text-center text-[10px] font-mono text-white/20 pointer-events-none">
        click anywhere · canvas · gravity · particle trails
      </p>
    </div>
  );
}

More in Canvas