← All particles

Confetti Burst

One popper, fired once: a tapered cone of paper with real gravity and tumble.

celebrationjoyfulenergetic56 particles · light · canvas-2d · automatic · finite
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

254 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Confetti Burst
 *
 * One burst, from one cannon, that ends. The shape of it comes from a
 * single rule: speed tapers with the angle off the barrel. A cannon
 * throws hardest straight up the axis and weakest at the edges of the
 * cone, so the cloud arrives as a fan with a defined nose. Give every
 * piece the same speed over a spread of angles and you get an expanding
 * circle, which reads as an explosion rather than as a popper.
 *
 * Everything else is honest physics on top of that — gravity, air drag,
 * and a tumble about each piece's own axis so a rectangle narrows to a
 * line and back. And the run terminates: when the last piece leaves the
 * frame the loop cancels itself, because a celebration holding a
 * requestAnimationFrame open forever is a battery bug, not a feature.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; fire again by incrementing `fireKey`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ConfettiBurstProps = {
  /** Visual character of the burst. */
  variant?: "subtle" | "default" | "playful";
  /** Increment to fire again. The burst also fires once on mount. */
  fireKey?: number;
  /** Pieces in the burst. Overrides the variant's density. */
  count?: number;
  /** Paper colors, sampled per piece. */
  colors?: string[];
  /** Where the cannon sits, as fractions of the width and height. */
  originX?: number;
  originY?: number;
  /** Fires once the last piece has left the frame. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** Pieces in the burst at this setting. */
  count: number;
  /**
   * Muzzle speed on the axis, in container heights per second — so the
   * burst fits whatever box it is dropped into instead of being tuned
   * for one card size.
   */
  speed: number;
  /** Gravity, also in container heights per second squared. */
  gravity: number;
  /** Half-angle of the cone, in degrees. */
  spread: number;
  /** Long edge of a piece in px. */
  size: number;
  /** Turns per second about the piece's own axis. */
  tumble: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A small acknowledgement — barely clears the middle of the frame.
  subtle: { count: 38, speed: 1.9, gravity: 4.4, spread: 24, size: 6, tumble: 1.4 },
  // A popper going off over a success card. All-purpose.
  default: { count: 56, speed: 2.5, gravity: 4.4, spread: 32, size: 7.5, tumble: 1.9 },
  // A wider, higher throw with more paper in the air.
  playful: { count: 76, speed: 3, gravity: 4.6, spread: 42, size: 9, tumble: 2.6 },
};

type Piece = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  /** Rotation about the axis running along the piece — the tumble. */
  flip: number;
  flipSpeed: number;
  /** Rotation in the plane of the screen. */
  tilt: number;
  tiltSpeed: number;
  width: number;
  height: number;
  color: string;
};

const DEFAULT_COLORS = ["#E8B341", "#E8695F", "#3FB0A5", "#6C74E8", "#E56FA8"];

/** How hard the air holds the paper back, per second. */
const DRAG = 1.25;

export default function ConfettiBurst({
  variant = "default",
  fireKey = 0,
  count,
  colors = DEFAULT_COLORS,
  originX = 0.5,
  originY = 1.02,
  onComplete,
}: ConfettiBurstProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The callback lives in a ref, updated in its own effect rather than
  // during render, so an inline arrow from the parent changes identity
  // every render without re-firing the burst.
  const completeRef = useRef(onComplete);
  useEffect(() => {
    completeRef.current = onComplete;
  }, [onComplete]);

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

    const config = VARIANTS[variant];
    const total = count ?? config.count;
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let ratio = 1;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
    };
    resize();

    const random = (min: number, max: number) => min + Math.random() * (max - min);
    const spread = (config.spread * Math.PI) / 180;
    const gravity = config.gravity * height;

    const load = (): Piece[] =>
      Array.from({ length: total }, (_, index) => {
        // Deal the angles across the cone rather than drawing them at
        // random: with fifty pieces, randomness leaves visible gaps.
        const offset = ((index + 0.5) / total) * 2 - 1 + random(-0.02, 0.02);
        const angle = offset * spread;
        // The taper. Fastest up the barrel, slowest at the cone's edge.
        const speed = config.speed * height * (1 - 0.42 * Math.abs(offset)) * random(0.9, 1.1);
        const long = config.size * random(0.8, 1.2);
        return {
          x: width * originX,
          y: height * originY,
          vx: Math.sin(angle) * speed,
          vy: -Math.cos(angle) * speed,
          flip: random(0, Math.PI * 2),
          flipSpeed: config.tumble * Math.PI * 2 * random(0.6, 1.4) * (Math.random() < 0.5 ? -1 : 1),
          tilt: random(0, Math.PI * 2),
          tiltSpeed: random(-2.5, 2.5),
          width: long,
          height: long * random(0.5, 0.75),
          color: colors[Math.floor(Math.random() * colors.length)],
        };
      });

    let pieces = load();

    const step = (delta: number) => {
      const hold = Math.exp(-DRAG * delta);
      for (const piece of pieces) {
        piece.vy += gravity * delta;
        piece.vx *= hold;
        piece.vy *= hold;
        piece.x += piece.vx * delta;
        piece.y += piece.vy * delta;
        piece.flip += piece.flipSpeed * delta;
        piece.tilt += piece.tiltSpeed * delta;
      }
      pieces = pieces.filter(
        (piece) => piece.y < height + 30 && piece.x > -40 && piece.x < width + 40
      );
    };

    const render = () => {
      context.clearRect(0, 0, width, height);
      for (const piece of pieces) {
        // Edge-on is what makes flat paper read as paper: the rectangle
        // narrows to a line and dims as it turns through the tumble.
        const face = Math.abs(Math.cos(piece.flip));
        if (face < 0.04) continue;
        context.save();
        context.translate(piece.x, piece.y);
        context.rotate(piece.tilt);
        context.globalAlpha = 0.55 + face * 0.45;
        context.fillStyle = piece.color;
        context.fillRect(
          (-piece.width * face) / 2,
          -piece.height / 2,
          piece.width * face,
          piece.height
        );
        context.restore();
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: the burst frozen a third of a second in, which is
    // the moment the cone is most legible. Something happened, and the
    // shape of it still says what.
    if (reduced) {
      for (let index = 0; index < 20; index++) step(1 / 60);
      render();
      const onResizeStill = () => {
        resize();
        pieces = load();
        for (let index = 0; index < 20; index++) step(1 / 60);
        render();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let last = performance.now();

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      step(delta);
      render();

      if (pieces.length === 0) {
        // The burst is over. Nothing is scheduled, nothing is retained.
        completeRef.current?.();
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => resize();
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, fireKey, count, colors, originX, originY]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

Confetti that behaves like a party popper rather than a loop. The shape comes from one rule — muzzle speed tapers with the angle off the barrel, fastest up the axis and slowest at the edge of the cone — so the cloud arrives as a fan with a nose. Give every piece the same speed across a spread of angles and you get an expanding circle, which reads as an explosion. On top of that: gravity and air drag in real units, and a tumble about each piece's own axis so the rectangle narrows to a line and dims as it turns. Speeds are expressed in container heights per second, so the burst fits whatever box it lands in. It fires once and the loop cancels itself when the last piece leaves the frame.

Order confirmedSignup completeGoal reachedPayment succeeded

Related effects