Skip to content

Confetti Burst Button

A red pill button with a pointer-following sheen that squashes on press and throws a real confetti burst from your click point, then morphs its label into a drawn check and resets.

Canvas 2D0 deps1 file · 12 KB TSXreduced-motion aware

Freeplayfulneon
Preview

Props

54
70
No runtime dependencies
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

type Piece = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  rot: number;
  spin: number;
  flip: number;
  flipSpeed: number;
  sway: number;
  swayPhase: number;
  w: number;
  h: number;
  shape: 0 | 1 | 2 | 3;
  color: string;
  age: number;
  life: number;
};

type Api = { fire: (ox: number, oy: number) => void };

const DEFAULT_COLORS = ["#ff4d6d", "#ffb703", "#2ee6a6", "#4cc9f0", "#a78bfa", "#f472b6"];
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
const GRAVITY = 760; // px/s^2
const DRAG_X = 4.4; // 1/s, horizontal air drag
const DRAG_Y = 2.9; // 1/s, vertical drag, sets the terminal fall speed (~260px/s)
const EASE_SPRING = "cubic-bezier(0.2, 0.9, 0.25, 1)";

const rand = (a: number, b: number) => a + Math.random() * (b - a);

export function ConfettiButton({
  label = "Click me",
  count = 18,
  spread = 70,
  doneLabel = "Done",
  colors,
  resetAfter = 1900,
  disabled = false,
  className = "",
  onClick,
}: {
  label?: string;
  /** Number of confetti pieces per burst (squares, dots, ribbons, triangles). */
  count?: number;
  /** How far the burst reaches, in px. */
  spread?: number;
  /** Text shown next to the check after a click. */
  doneLabel?: string;
  /** Hex colours for the pieces. */
  colors?: string[];
  /** Milliseconds the "Done" state stays before the label resets. */
  resetAfter?: number;
  disabled?: boolean;
  className?: string;
  onClick?: () => void;
}) {
  const [done, setDone] = useState(false);
  const btnRef = useRef<HTMLButtonElement>(null);
  const ringRef = useRef<HTMLSpanElement>(null);
  const glowRef = useRef<HTMLSpanElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const apiRef = useRef<Api | null>(null);
  const resetTimer = useRef(0);
  const reduced = useRef(false);
  const cfg = useRef({ count, spread, colors: DEFAULT_COLORS });

  useEffect(() => {
    const valid = (colors ?? []).filter((c) => HEX.test(c));
    cfg.current = { count, spread, colors: valid.length ? valid : DEFAULT_COLORS };
  }, [count, spread, colors]);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reduced.current = mq.matches;
    const on = () => {
      reduced.current = mq.matches;
    };
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);

  useEffect(() => () => window.clearTimeout(resetTimer.current), []);

  // The particle engine lives in one mount effect: it owns the pieces, the canvas and the frame loop.
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    let pieces: Piece[] = [];
    let raf = 0;
    let last = 0;
    let dpr = 1;
    let size = 0;

    const clear = () => {
      canvas.width = 0;
      canvas.height = 0;
      size = 0;
    };

    const frame = (t: number) => {
      const dt = Math.min(0.034, Math.max(0.001, (t - last) / 1000));
      last = t;
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      const drag = Math.exp(-DRAG_X * dt);
      const dragY = Math.exp(-DRAG_Y * dt);
      const keep: Piece[] = [];
      for (const p of pieces) {
        p.age += dt;
        if (p.age >= p.life) continue;
        p.vx *= drag;
        p.vy = (p.vy + GRAVITY * dt) * dragY;
        p.x += (p.vx + Math.sin(p.age * 5.2 + p.swayPhase) * p.sway * Math.min(1, p.age * 2)) * dt;
        p.y += p.vy * dt;
        p.rot += p.spin * dt;
        p.flip += p.flipSpeed * dt;
        const u = p.age / p.life;
        const fade = u < 0.62 ? 1 : 1 - (u - 0.62) / 0.38;
        const tilt = Math.cos(p.flip);
        ctx.save();
        ctx.translate(p.x + size, p.y + size);
        ctx.rotate(p.rot);
        ctx.scale(1, tilt);
        ctx.globalAlpha = Math.max(0, fade) * (0.72 + 0.28 * Math.abs(tilt));
        ctx.fillStyle = p.color;
        if (p.shape === 0) {
          ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
        } else if (p.shape === 1) {
          ctx.beginPath();
          ctx.arc(0, 0, p.h * 0.62, 0, Math.PI * 2);
          ctx.fill();
        } else if (p.shape === 2) {
          ctx.fillRect(-p.w * 1.1, -1.3, p.w * 2.2, 2.6);
        } else {
          ctx.beginPath();
          ctx.moveTo(0, -p.h * 0.8);
          ctx.lineTo(p.w * 0.6, p.h * 0.6);
          ctx.lineTo(-p.w * 0.6, p.h * 0.6);
          ctx.closePath();
          ctx.fill();
        }
        ctx.restore();
        keep.push(p);
      }
      pieces = keep;
      if (pieces.length) {
        raf = requestAnimationFrame(frame);
      } else {
        raf = 0;
        clear();
      }
    };

    apiRef.current = {
      fire(ox, oy) {
        const { count: n, spread: s, colors: palette } = cfg.current;
        const reach = Math.max(20, s);
        const R = Math.round(reach * 1.9 + 280);
        dpr = Math.min(2, window.devicePixelRatio || 1);
        if (size !== R) {
          size = R;
          canvas.width = Math.round(R * 2 * dpr);
          canvas.height = Math.round(R * 2 * dpr);
          canvas.style.width = R * 2 + "px";
          canvas.style.height = R * 2 + "px";
          canvas.style.margin = -R + "px 0 0 " + -R + "px";
        }
        const total = Math.max(1, Math.round(n));
        for (let i = 0; i < total; i++) {
          const up = Math.random() < 0.72;
          const a = up ? -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.55 : Math.random() * Math.PI * 2;
          const speed = reach * DRAG_X * rand(0.38, 1.18);
          const shape = ([0, 0, 1, 2, 3] as const)[Math.floor(Math.random() * 5)] ?? 0;
          pieces.push({
            x: ox + Math.cos(a) * 6,
            y: oy + Math.sin(a) * 6,
            vx: Math.cos(a) * speed,
            vy: Math.sin(a) * speed,
            rot: rand(0, Math.PI * 2),
            spin: rand(-9, 9),
            flip: rand(0, Math.PI * 2),
            flipSpeed: rand(6, 17),
            sway: rand(10, 42),
            swayPhase: rand(0, 6.28),
            w: rand(6, 11),
            h: rand(4, 7.5),
            shape,
            color: palette[Math.floor(Math.random() * palette.length)] ?? "#fff",
            age: 0,
            life: rand(1.35, 2.3),
          });
        }
        if (!raf) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      },
    };

    return () => {
      apiRef.current = null;
      cancelAnimationFrame(raf);
      raf = 0;
    };
  }, []);

  const move = useCallback((e: React.PointerEvent<HTMLButtonElement>) => {
    const el = btnRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const k = r.width ? el.offsetWidth / r.width : 1;
    el.style.setProperty("--cb-x", (e.clientX - r.left) * k + "px");
    el.style.setProperty("--cb-y", (e.clientY - r.top) * k + "px");
  }, []);

  const click = (e: React.MouseEvent<HTMLButtonElement>) => {
    const el = btnRef.current;
    if (!el || disabled) return;
    const r = el.getBoundingClientRect();
    const k = r.width ? el.offsetWidth / r.width : 1;
    const w = el.offsetWidth;
    const h = el.offsetHeight;
    // Pointer clicks burst from the pointer; keyboard activation (detail 0) bursts from the centre.
    const px = e.detail === 0 ? w / 2 : Math.min(w, Math.max(0, (e.clientX - r.left) * k));
    const py = e.detail === 0 ? h / 2 : Math.min(h, Math.max(0, (e.clientY - r.top) * k));
    const calm = reduced.current;
    if (!calm) {
      apiRef.current?.fire(px - w / 2, py - h / 2);
      el.animate(
        [
          { transform: "scale(1, 1)" },
          { transform: "scale(1.08, 0.93)", offset: 0.28 },
          { transform: "scale(0.98, 1.03)", offset: 0.58 },
          { transform: "scale(1, 1)" },
        ],
        { duration: 560, easing: EASE_SPRING },
      );
      ringRef.current?.animate(
        [
          { opacity: 0.55, transform: "scale(1)" },
          { opacity: 0, transform: "scale(1.55, 1.9)" },
        ],
        { duration: 720, easing: "cubic-bezier(0.1, 0.7, 0.2, 1)" },
      );
    } else {
      glowRef.current?.animate([{ opacity: 0 }, { opacity: 1, offset: 0.35 }, { opacity: 0 }], {
        duration: 900,
        easing: "ease-in-out",
      });
    }
    setDone(true);
    window.clearTimeout(resetTimer.current);
    resetTimer.current = window.setTimeout(() => setDone(false), Math.max(400, resetAfter));
    onClick?.();
  };

  const swap = (on: boolean) => ({
    opacity: on ? 1 : 0,
    transform: on ? "translateY(0) scale(1)" : "translateY(" + (done ? "-9px" : "9px") + ") scale(0.94)",
    filter: on ? "blur(0px)" : "blur(4px)",
    transition:
      "opacity 320ms " + EASE_SPRING + ", transform 420ms " + EASE_SPRING + ", filter 320ms " + EASE_SPRING,
  });

  return (
    <span className={"relative inline-flex " + className}>
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 z-10" width={0} height={0} />
      <span
        ref={ringRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-0"
        style={{ borderRadius: 9999, boxShadow: "0 0 0 1.5px rgba(239,68,68,0.7), 0 0 24px 2px rgba(239,68,68,0.35)" }}
      />
      <span
        ref={glowRef}
        aria-hidden="true"
        className="pointer-events-none absolute -inset-1 opacity-0"
        style={{ borderRadius: 9999, boxShadow: "0 0 0 2px rgba(46,230,166,0.8), 0 0 36px 6px rgba(46,230,166,0.5)" }}
      />
      <button
        ref={btnRef}
        type="button"
        disabled={disabled}
        onClick={click}
        onPointerMove={move}
        className="group relative inline-flex min-h-12 cursor-pointer select-none items-center justify-center overflow-hidden whitespace-nowrap px-8 text-[15px] font-semibold tracking-[-0.01em] transition-[scale,translate,box-shadow] duration-150 enabled:hover:-translate-y-px enabled:active:scale-[0.95] disabled:cursor-not-allowed disabled:opacity-45 focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-4 focus-visible:!outline-[#6366f1] [-webkit-tap-highlight-color:transparent] touch-manipulation"
        style={{
          borderRadius: 9999,
          color: "#ffffff",
          background: "linear-gradient(180deg, #ff4a4a 0%, #e0141f 100%)",
          boxShadow:
            "inset 0 1px 0 rgba(255,255,255,0.4), inset 0 -1px 0 rgba(90,0,8,0.25), 0 12px 26px -8px rgba(224,20,31,0.55)",
          ["--cb-x" as string]: "50%",
          ["--cb-y" as string]: "50%",
        }}
      >
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-enabled:group-hover:opacity-100"
          style={{ background: "radial-gradient(130px circle at var(--cb-x) var(--cb-y), rgba(255,255,255,0.34), rgba(255,255,255,0.1) 45%, transparent 72%)" }}
        />
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
          style={{
            background: "linear-gradient(180deg, #2fd27a 0%, #15a05a 100%)",
            opacity: done ? 1 : 0,
            transition: "opacity 380ms ease",
          }}
        />
        <span className="relative grid place-items-center">
          <span className="col-start-1 row-start-1 inline-flex items-center gap-2" style={swap(!done)} aria-hidden={done}>
            {label}
          </span>
          <span className="col-start-1 row-start-1 inline-flex items-center gap-2" style={swap(done)} aria-hidden={!done}>
            <svg width="17" height="17" viewBox="0 0 20 20" fill="none" aria-hidden="true">
              <path
                d="M4.5 10.5l3.6 3.6 7.4-8"
                stroke="#ffffff"
                strokeWidth="2.4"
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeDasharray="22"
                style={{ strokeDashoffset: done ? 0 : 22, transition: "stroke-dashoffset 420ms cubic-bezier(0.3, 0.7, 0.2, 1) " + (done ? "120ms" : "0ms") }}
              />
            </svg>
            {doneLabel}
          </span>
        </span>
      </button>
      <span role="status" className="sr-only">
        {done ? doneLabel : ""}
      </span>
    </span>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

A red pill button for the moment something goes right. Pressing it squashes the button, and on release it settles back with a short elastic overshoot while a ring expands off its edge. Confetti launches from the exact point you clicked (the centre for keyboard activation): rectangles, dots, thin ribbons and triangles, fanned mostly upward, then slowed by air drag, pulled down by gravity, swaying sideways and tumbling so they flip edge-on and darken like real paper before fading out. The label cross-fades with a blur into a check that draws itself plus a Done label, the surface turns green, and after about 1.9 seconds it resets; clicking again restarts the burst and the timer. Both labels share one grid cell, so the button never changes width. Particles live on one pointer-events-none canvas centred on the button that sizes itself to the spread, runs a single rAF loop only while pieces are alive and releases its backing store when idle. Props: label, count (number of pieces), spread, doneLabel, colors (hex strings), resetAfter, disabled, className, onClick. React only, no dependencies. The canvas can overflow the button, so keep ancestors that clip (overflow hidden) in mind when you place it.

Curator’s note

Rebuilt in October from a handful of CSS squares into a small Canvas 2D physics burst, with no library; the randomness still only ever runs inside the click handler.

Pairs well with

Trending
NewPro
Trending
Pro