Firework Bloom
One shell rises, bursts, and the sparks fall against real air drag.
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.
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.