Confetti Button

Fires a confetti burst from the button on click — no canvas, no library.

Edit with AIPro

Describe a change to Confetti Button and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/confetti-button.tsx

    components/ui/confetti-button.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { PartyPopper } from "lucide-react";
    import { useState, type ReactNode } from "react";
    
    const COLORS = ["#2563eb", "#7c3aed", "#db2777", "#f59e0b", "#10b981", "#06b6d4"];
    
    type Piece = { id: number; x: number; y: number; rotate: number; color: string; round: boolean };
    
    function burst(seed: number, count: number): Piece[] {
      return Array.from({ length: count }, (_, i) => {
        const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5;
        const distance = 60 + Math.random() * 90;
        return { id: seed * 1000 + i, x: Math.cos(angle) * distance, y: Math.sin(angle) * distance - 40, rotate: Math.random() * 540 - 270, color: COLORS[i % COLORS.length], round: i % 3 === 0 };
      });
    }
    
    /** Button that fires a confetti burst from its centre on click. No canvas, no library. */
    export function ConfettiButton({ children, pieces = 26, onClick }: { children: ReactNode; pieces?: number; onClick?: () => void }) {
      const [bursts, setBursts] = useState<{ id: number; pieces: Piece[] }[]>([]);
    
      function fire() {
        onClick?.();
        if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
        const id = Date.now();
        setBursts((b) => [...b, { id, pieces: burst(id, pieces) }]);
        setTimeout(() => setBursts((b) => b.filter((x) => x.id !== id)), 1400);
      }
    
      return (
        <span className="relative inline-block">
          <motion.button whileTap={{ scale: 0.94 }} onClick={fire} className="relative z-10 inline-flex items-center gap-2 rounded-full bg-zinc-900 px-6 py-3 text-sm font-medium text-white shadow-lg hover:bg-zinc-800">
            {children}
          </motion.button>
          <span aria-hidden className="pointer-events-none absolute left-1/2 top-1/2">
            <AnimatePresence>
              {bursts.flatMap((b) =>
                b.pieces.map((p) => (
                  <motion.i
                    key={p.id}
                    className="absolute block"
                    style={{ width: p.round ? 8 : 6, height: p.round ? 8 : 12, borderRadius: p.round ? 999 : 2, background: p.color }}
                    initial={{ x: 0, y: 0, opacity: 1, rotate: 0, scale: 0.6 }}
                    animate={{ x: p.x, y: [0, p.y, p.y + 120], opacity: [1, 1, 0], rotate: p.rotate, scale: 1 }}
                    transition={{ duration: 1.2, ease: [0.2, 0.8, 0.3, 1], times: [0, 0.45, 1] }}
                  />
                )),
              )}
            </AnimatePresence>
          </span>
        </span>
      );
    }
    
    export default function Demo() {
      return (
        <div className="grid h-64 place-items-center">
          <ConfettiButton>
            <PartyPopper className="size-4" /> Celebrate
          </ConfettiButton>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.