← All particles

Firework Bloom

One shell rises, bursts, and the sparks fall against real air drag.

celebrationjoyfuldramatic70 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.

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

/**
 * Vibary · Firework Bloom
 *
 * One shell rises, bursts, and the sparks fall. The detail that makes
 * it a firework rather than a decal is that the burst inherits the
 * shell's velocity: the sparks are a sphere expanding about a *moving*
 * point, so the whole bloom keeps drifting up, stalls, and comes down
 * as one body. Burst about a fixed point and it reads as a sticker
 * pasted onto the sky.
 *
 * The second half is drag. Sparks lose most of their speed in the
 * first fifth of a second — that hard deceleration is what carves the
 * crisp outer shell of the sphere — and only then does gravity take
 * over and bend the trails downward. Each spark is drawn as a line
 * from where it was last frame to where it is now, so its streak
 * length is its speed, for free.
 *
 * 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 FireworkBloomProps = {
  /** Visual character of the shell. */
  variant?: "subtle" | "default" | "playful";
  /** Increment to launch again. It also launches once on mount. */
  fireKey?: number;
  /** Sparks in the bloom. Overrides the variant's density. */
  count?: number;
  /** Shell colors; each launch takes the next one. */
  colors?: string[];
  /** Where the shell leaves from, as a fraction of the width. */
  originX?: number;
  /** Fires once the last spark has gone out. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** Sparks in the bloom at this setting. */
  count: number;
  /**
   * Muzzle speed of the shell, in container heights per second — so
   * one shell fits whatever box it is dropped into.
   */
  rise: number;
  /** Spark speed at the moment of the burst, same units. */
  scatter: number;
  /** How hard the air holds a spark back, per second. */
  drag: number;
  /** Seconds a spark stays lit. */
  life: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A small shell, low, that does not ask for the whole screen.
  subtle: { count: 54, rise: 1.6, scatter: 0.55, drag: 2.7, life: 1.3 },
  // One good firework. All-purpose.
  default: { count: 70, rise: 1.85, scatter: 0.72, drag: 2.4, life: 1.6 },
  // Higher and wider, with sparks that hang longer on the way down.
  playful: { count: 88, rise: 2.05, scatter: 0.92, drag: 2.2, life: 1.9 },
};

type Spark = {
  x: number;
  y: number;
  /** Where it was last frame — the streak is the difference. */
  previousX: number;
  previousY: number;
  vx: number;
  vy: number;
  age: number;
  life: number;
  drag: number;
  weight: number;
};

const DEFAULT_COLORS = ["#E8B341", "#6C9BE8", "#E8695F", "#5FCBA8"];

/** Gravity on the shell and on the sparks, in container heights per second squared. */
const SHELL_GRAVITY = 2.2;
const SPARK_GRAVITY = 0.55;

export default function FireworkBloom({
  variant = "default",
  fireKey = 0,
  count,
  colors = DEFAULT_COLORS,
  originX = 0.5,
  onComplete,
}: FireworkBloomProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The callback is held in a ref and updated in its own effect, so an
  // inline arrow from the parent cannot relaunch the shell.
  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 color = colors[Math.abs(fireKey) % colors.length];
    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);

    let shell = {
      x: width * originX,
      y: height * 1.02,
      previousY: height * 1.02,
      vx: random(-0.06, 0.06) * height,
      vy: -config.rise * height,
    };
    let sparks: Spark[] = [];
    let burst = false;

    const detonate = () => {
      burst = true;
      sparks = Array.from({ length: total }, (_, index) => {
        // Angles dealt evenly around the circle with a little jitter:
        // pure randomness leaves holes that read as a broken shell.
        const angle =
          ((index + 0.5) / total) * Math.PI * 2 + random(-0.05, 0.05);
        const speed = config.scatter * height * random(0.72, 1);
        // A few heavier sparks fall further and burn longer — the tail
        // of a real bloom, and the reason it does not end all at once.
        const heavy = Math.random() < 0.15;
        return {
          x: shell.x,
          y: shell.y,
          previousX: shell.x,
          previousY: shell.y,
          // The inheritance: the sphere is centred on a point that is
          // still travelling, so the whole bloom drifts and then falls.
          vx: Math.cos(angle) * speed + shell.vx * 0.55,
          vy: Math.sin(angle) * speed + shell.vy * 0.55,
          age: 0,
          life: config.life * (heavy ? random(1.15, 1.4) : random(0.7, 1)),
          drag: config.drag * (heavy ? 0.7 : 1),
          weight: heavy ? 1.35 : 1,
        };
      });
    };

    const step = (delta: number) => {
      if (!burst) {
        shell.previousY = shell.y;
        shell.vy += SHELL_GRAVITY * height * delta;
        shell.x += shell.vx * delta;
        shell.y += shell.vy * delta;
        // Burst near the top of the arc, where the shell has lost most
        // of its speed — not at a fixed height, which never looks right
        // across container sizes.
        if (shell.vy > -config.rise * height * 0.25) detonate();
        return;
      }
      for (const spark of sparks) {
        const hold = Math.exp(-spark.drag * delta);
        spark.previousX = spark.x;
        spark.previousY = spark.y;
        spark.vx *= hold;
        spark.vy *= hold;
        spark.vy += SPARK_GRAVITY * height * spark.weight * delta;
        spark.x += spark.vx * delta;
        spark.y += spark.vy * delta;
        spark.age += delta;
      }
      sparks = sparks.filter((spark) => spark.age < spark.life && spark.y < height + 40);
    };

    const render = () => {
      context.clearRect(0, 0, width, height);
      context.lineCap = "round";
      context.strokeStyle = color;

      if (!burst) {
        // The shell itself: a short streak, so the climb has a direction.
        context.globalAlpha = 0.9;
        context.lineWidth = 2;
        context.beginPath();
        context.moveTo(shell.x, shell.previousY + 6);
        context.lineTo(shell.x, shell.y);
        context.stroke();
      }

      for (const spark of sparks) {
        const t = spark.age / spark.life;
        // Holds its brightness, then goes out over the last third —
        // a linear fade from the first frame reads as a dissolve.
        const fade = t < 0.65 ? 1 : 1 - (t - 0.65) / 0.35;
        context.globalAlpha = Math.max(0, fade) * 0.92;
        context.lineWidth = (t < 0.15 ? 2 : 1.4) * spark.weight;
        context.beginPath();
        context.moveTo(spark.previousX, spark.previousY);
        context.lineTo(spark.x, spark.y);
        context.stroke();
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: the bloom a third of a second after the burst,
    // held still. The sphere with its streaks pointing outward is the
    // recognisable moment; the rise and the fall only lead to it.
    if (reduced) {
      const settle = () => {
        burst = false;
        shell = {
          x: width * originX,
          y: height * 0.34,
          previousY: height * 0.34,
          vx: 0,
          vy: -config.rise * height * 0.2,
        };
        detonate();
        for (let index = 0; index < 20; index++) step(1 / 60);
        render();
      };
      settle();
      const onResizeStill = () => {
        resize();
        settle();
      };
      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 (burst && sparks.length === 0) {
        // Over. Nothing is scheduled and 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]);

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

About this effect

A single firework for a milestone worth marking. Two things make it read as one instead of as a sticker on the sky. The burst inherits the shell's velocity, so the sphere expands about a point that is still travelling — the bloom drifts up, stalls and comes down as one body. And drag does the shaping: sparks lose most of their speed in the first fifth of a second, which is what carves the crisp outer edge, and only then does gravity bend the trails downward. Each spark is drawn as a line from its last position to its current one, so streak length is speed for free. A few heavier sparks fall further and burn longer, so the bloom has a tail instead of ending all at once.

Milestone reachedCampaign launchedYear in reviewLevel complete

Related effects