Confetti Burst

Physics-based confetti explosion triggered imperatively via ref from any button

confetticanvascelebrationphysicsfeedback
Preview

Source Code

"use client";

import {
  forwardRef,
  useImperativeHandle,
  useRef,
  useEffect,
  useCallback,
} from "react";
import { createPortal } from "react-dom";

export interface ConfettiBurstRef {
  fire: (options?: FireOptions) => void;
}

interface FireOptions {
  /** Origin in element-relative 0..1 coords. Default: {x:0.5,y:0.5} */
  origin?: { x: number; y: number };
  /** Particle count. Default: 80 */
  count?: number;
  /** Cone spread in degrees. Default: 110 */
  spread?: number;
  /** Initial speed (px/frame). Default: 9 */
  velocity?: number;
  /** Gravity per frame (px/frame^2). Default: 0.55 */
  gravity?: number;
  /** Color palette. Default: brand colors */
  colors?: string[];
  /** Shape variants. Default: ['rectangle','square','circle'] */
  shapes?: Array<"circle" | "square" | "rectangle" | "star">;
  /** Particle lifetime in ms. Default: 2200 */
  lifetime?: number;
}

interface ConfettiBurstProps {
  /** Render as full-page overlay via portal. Default: false */
  fullscreen?: boolean;
  className?: string;
}

type Shape = "circle" | "square" | "rectangle" | "star";

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  rotation: number;
  vRotation: number;
  color: string;
  shape: Shape;
  w: number;
  h: number;
  wobble: number;
  wobbleSpeed: number;
  wobbleAmp: number;
  gravity: number;
  life: number;
  lifetime: number;
}

const DEFAULT_COLORS = ["#f59e0b", "#ec4899", "#8b5cf6", "#10b981", "#06b6d4"];

function drawStar(
  ctx: CanvasRenderingContext2D,
  cx: number,
  cy: number,
  r: number
) {
  const spikes = 5;
  const innerR = r * 0.45;
  let angle = -Math.PI / 2;
  const step = (Math.PI * 2) / spikes;
  ctx.beginPath();
  for (let i = 0; i < spikes; i++) {
    ctx.lineTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
    angle += step / 2;
    ctx.lineTo(cx + Math.cos(angle) * innerR, cy + Math.sin(angle) * innerR);
    angle += step / 2;
  }
  ctx.closePath();
}

const ConfettiBurst = forwardRef<ConfettiBurstRef, ConfettiBurstProps>(
  function ConfettiBurst({ fullscreen = false, className = "" }, ref) {
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const particlesRef = useRef<Particle[]>([]);
    const rafRef = useRef<number>(0);
    const lastTimeRef = useRef<number>(0);
    const runningRef = useRef(false);

    const getCanvas = useCallback(() => canvasRef.current, []);
    const tickRef = useRef<(now: number) => void>(() => {});

    const tick = useCallback((now: number) => {
      const canvas = getCanvas();
      if (!canvas) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;

      const dt = lastTimeRef.current ? now - lastTimeRef.current : 16;
      lastTimeRef.current = now;

      const dpr = window.devicePixelRatio || 1;
      const cssW = canvas.width / dpr;
      const cssH = canvas.height / dpr;

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      particlesRef.current = particlesRef.current.filter((p) => p.life < 1);

      for (const p of particlesRef.current) {
        // Integrate physics
        p.vy += p.gravity;
        p.vx *= 0.995;
        p.vy *= 0.995;
        p.wobble += p.wobbleSpeed;
        p.x += p.vx + Math.sin(p.wobble) * p.wobbleAmp;
        p.y += p.vy;
        p.rotation += p.vRotation;
        p.life += dt / p.lifetime;

        // Hold full alpha until last 30% of life, then fade
        const alpha =
          p.life < 0.7 ? 1 : Math.max(0, 1 - (p.life - 0.7) / 0.3);
        ctx.globalAlpha = alpha;
        ctx.fillStyle = p.color;

        ctx.save();
        ctx.translate(p.x * dpr, p.y * dpr);
        ctx.rotate(p.rotation);
        // Flat paper flip: scale Y based on rotation to fake 3D tumble
        const flip = Math.abs(Math.cos(p.wobble * 0.8));
        ctx.scale(1, 0.3 + flip * 0.7);

        if (p.shape === "circle") {
          ctx.beginPath();
          ctx.arc(0, 0, (p.w * dpr) / 2, 0, Math.PI * 2);
          ctx.fill();
        } else if (p.shape === "square" || p.shape === "rectangle") {
          const w = p.w * dpr;
          const h = p.h * dpr;
          ctx.fillRect(-w / 2, -h / 2, w, h);
        } else {
          drawStar(ctx, 0, 0, (p.w * dpr) / 2);
          ctx.fill();
        }
        ctx.restore();

        // Kill when far off-screen in any direction
        if (
          p.x < -cssW * 0.5 ||
          p.x > cssW * 1.5 ||
          p.y > cssH * 1.5 ||
          p.y < -cssH * 0.8
        ) {
          p.life = 1;
        }
      }

      ctx.globalAlpha = 1;

      if (particlesRef.current.length > 0) {
        rafRef.current = requestAnimationFrame((t) => tickRef.current(t));
      } else {
        runningRef.current = false;
        lastTimeRef.current = 0;
      }
    }, [getCanvas]);

    useEffect(() => {
      tickRef.current = tick;
    }, [tick]);

    const fire = useCallback(
      (options: FireOptions = {}) => {
        const canvas = getCanvas();
        if (!canvas) return;

        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
          const ctx = canvas.getContext("2d");
          if (!ctx) return;
          const dpr = window.devicePixelRatio || 1;
          const ox = (options.origin?.x ?? 0.5) * (canvas.width / dpr);
          const oy = (options.origin?.y ?? 0.5) * (canvas.height / dpr);
          const grad = ctx.createRadialGradient(
            ox * dpr,
            oy * dpr,
            0,
            ox * dpr,
            oy * dpr,
            80 * dpr
          );
          grad.addColorStop(0, "rgba(245,158,11,0.7)");
          grad.addColorStop(1, "rgba(245,158,11,0)");
          ctx.fillStyle = grad;
          ctx.beginPath();
          ctx.arc(ox * dpr, oy * dpr, 80 * dpr, 0, Math.PI * 2);
          ctx.fill();
          setTimeout(() => {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
          }, 400);
          return;
        }

        const {
          origin = { x: 0.5, y: 0.5 },
          count = 80,
          spread = 110,
          velocity = 9,
          gravity = 0.55,
          colors = DEFAULT_COLORS,
          shapes = ["rectangle", "square", "circle"] as Shape[],
          lifetime = 2200,
        } = options;

        const dpr = window.devicePixelRatio || 1;
        const cssW = canvas.width / dpr;
        const cssH = canvas.height / dpr;
        const ox = origin.x * cssW;
        const oy = origin.y * cssH;

        // Burst cone centered upward
        const centerAngle = -Math.PI / 2;
        const halfSpread = ((spread * Math.PI) / 180) * 0.5;

        const newParticles: Particle[] = Array.from({ length: count }, () => {
          const angle =
            centerAngle + (Math.random() - 0.5) * 2 * halfSpread;
          // Varied speed for depth — some particles are "closer" and fly further
          const speed = velocity * (0.6 + Math.random() * 0.8);
          const shape = shapes[Math.floor(Math.random() * shapes.length)];
          const w =
            shape === "rectangle"
              ? 7 + Math.random() * 5
              : 4 + Math.random() * 4;
          const h =
            shape === "rectangle" ? 3 + Math.random() * 2 : w;
          return {
            x: ox + (Math.random() - 0.5) * 8,
            y: oy + (Math.random() - 0.5) * 8,
            vx: Math.cos(angle) * speed,
            vy: Math.sin(angle) * speed,
            rotation: Math.random() * Math.PI * 2,
            vRotation: (Math.random() - 0.5) * 0.35,
            color: colors[Math.floor(Math.random() * colors.length)],
            shape,
            w,
            h,
            wobble: Math.random() * Math.PI * 2,
            wobbleSpeed: 0.1 + Math.random() * 0.1,
            wobbleAmp: 0.6 + Math.random() * 0.8,
            gravity,
            life: 0,
            lifetime,
          };
        });

        particlesRef.current.push(...newParticles);

        if (!runningRef.current) {
          runningRef.current = true;
          lastTimeRef.current = 0;
          rafRef.current = requestAnimationFrame((t) => tickRef.current(t));
        }
      },
      [getCanvas]
    );

    useImperativeHandle(ref, () => ({ fire }), [fire]);

    useEffect(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;

      const resize = () => {
        const dpr = window.devicePixelRatio || 1;
        let cssW: number;
        let cssH: number;
        if (fullscreen) {
          cssW = window.innerWidth;
          cssH = window.innerHeight;
        } else {
          const parent = canvas.parentElement;
          if (!parent) return;
          cssW = parent.clientWidth;
          cssH = parent.clientHeight;
        }
        canvas.width = cssW * dpr;
        canvas.height = cssH * dpr;
        canvas.style.width = `${cssW}px`;
        canvas.style.height = `${cssH}px`;
      };

      resize();

      const ro = new ResizeObserver(resize);
      if (fullscreen) {
        window.addEventListener("resize", resize);
      } else {
        const parent = canvas.parentElement;
        if (parent) ro.observe(parent);
      }

      return () => {
        cancelAnimationFrame(rafRef.current);
        ro.disconnect();
        window.removeEventListener("resize", resize);
      };
    }, [fullscreen]);

    const canvasEl = (
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className={
          fullscreen
            ? `pointer-events-none ${className}`
            : `absolute inset-0 pointer-events-none ${className}`
        }
        style={
          fullscreen
            ? { position: "fixed", inset: 0, zIndex: 9999 }
            : undefined
        }
      />
    );

    if (fullscreen && typeof window !== "undefined") {
      return createPortal(canvasEl, document.body);
    }

    return canvasEl;
  }
);

export default ConfettiBurst;