ORBIT.

Components

Confetti Button

A button that bursts confetti from the click position, with physics-based particles that drift and fade.

Preview

Installation

npx shadcn@latest add https://orbit.ruturaj.xyz/r/confetti-button

Code

"use client";

import { ButtonHTMLAttributes, useEffect, useRef } from "react";

const COLORS = ["#ff6b6b", "#ffd93d", "#6bcb77", "#4d96ff", "#ff922b", "#cc5de8", "#f06595"];

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  color: string;
  size: number;
  rotation: number;
  rotationSpeed: number;
  opacity: number;
  shape: "rect" | "circle";
}

interface ConfettiButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
  particleCount?: number;
}

export function ConfettiButton({
  children,
  className = "",
  disabled,
  particleCount = 80,
  onClick,
  ...props
}: ConfettiButtonProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const animRef = useRef<number | null>(null);

  useEffect(() => {
    return () => {
      if (animRef.current) cancelAnimationFrame(animRef.current);
    };
  }, []);

  function launch(originX: number, originY: number) {
    if (animRef.current) cancelAnimationFrame(animRef.current);

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

    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const particles: Particle[] = Array.from({ length: particleCount }, () => ({
      x: originX,
      y: originY,
      vx: (Math.random() - 0.5) * 16,
      vy: Math.random() * -14 - 4,
      color: COLORS[Math.floor(Math.random() * COLORS.length)],
      size: Math.random() * 8 + 4,
      rotation: Math.random() * Math.PI * 2,
      rotationSpeed: (Math.random() - 0.5) * 0.35,
      opacity: 1,
      shape: Math.random() > 0.5 ? "rect" : "circle",
    }));

    function tick() {
      if (!ctx || !canvas) return;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      let alive = false;

      for (const p of particles) {
        if (p.opacity <= 0) continue;
        alive = true;

        p.x += p.vx;
        p.y += p.vy;
        p.vy += 0.45;
        p.vx *= 0.99;
        p.rotation += p.rotationSpeed;
        p.opacity -= 0.013;

        ctx.save();
        ctx.globalAlpha = Math.max(0, p.opacity);
        ctx.translate(p.x, p.y);
        ctx.rotate(p.rotation);
        ctx.fillStyle = p.color;

        if (p.shape === "circle") {
          ctx.beginPath();
          ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2);
          ctx.fill();
        } else {
          ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
        }

        ctx.restore();
      }

      if (alive) {
        animRef.current = requestAnimationFrame(tick);
      } else {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
      }
    }

    animRef.current = requestAnimationFrame(tick);
  }

  function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
    launch(e.clientX, e.clientY);
    onClick?.(e);
  }

  return (
    <>
      <canvas
        ref={canvasRef}
        className="fixed inset-0 pointer-events-none z-50"
        aria-hidden="true"
      />
      <button
        onClick={handleClick}
        disabled={disabled}
        className={`select-none rounded-[8px] bg-zinc-900 px-6 py-3 text-sm font-medium tracking-wide text-white cursor-pointer hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
        {...props}
      >
        {children}
      </button>
    </>
  );
}