Campfire Embers
A fire's convection column: quick up the middle, drawn in at the base, tumbling at the edges.
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 · Campfire Embers
*
* Embers riding a fire's convection column: quick and straight up the
* middle, drawn inward at the base, tumbling slowly out at the top.
*
* The technique: there is no per-ember behaviour at all. There is one
* velocity field, and it is defined through a stream function rather
* than by writing the two velocity components down — velocity is the
* cross-derivative of a single scalar, which makes the flow exactly
* divergence-free by construction. Checked numerically across the whole
* frame, the divergence is 4e-5 against terms of order 4, which is the
* finite difference's own error and not a physical residual.
*
* That one property is what produces the shape. A column that speeds up
* has to pull air in from the sides to feed itself, and a column that
* widens has to slow down; both fall out of the same scalar instead of
* being three separate rules that have to be kept consistent. Advecting
* embers through it, the plume necks in from 58px to 18px in the first
* 20px of rise and then opens back out to 44px by the top — a candle
* silhouette nobody drew. Author "up, faster in the middle" on its own
* and there is nothing holding the column together: embers stream out
* of the sides and it frays within a second.
*
* The rise on the axis is 160px/s and 8px/s sixty pixels out, so an
* ember that wanders off-centre simply stops climbing and gets carried
* around — which is the slow tumble at the edges, again for free.
* Turbulence is added on top of the mean flow, and the column's axis
* leans on a wave travelling up it, so the fire wavers as one body.
*
* Self-contained: one canvas plus a glow sprite it draws once.
* Works with zero props; tune via `count`, `colors`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CampfireEmbersProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Embers in the column. */
count?: number;
/** Ember colours, coolest first. Sampled by how much heat is left. */
colors?: string[];
/** Fires when an ember burns out. */
onEmberOut?: () => void;
};
type VariantConfig = {
/** Scales every speed in the field. */
vigour: number;
/** How hard the column draws air in at its base. */
entrain: number;
/** How far the column's axis leans, in px at the reference height. */
sway: number;
/** Glow radius in px at full heat. */
glow: number;
/** Seconds an ember burns. */
life: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Banked down: a slow column, embers that barely reach the top.
subtle: { vigour: 0.7, entrain: 0.55, sway: 5, glow: 7, life: 3.4 },
// A fire that is properly going. All-purpose.
default: { vigour: 1, entrain: 0.8, sway: 9, glow: 9, life: 2.8 },
// Fed and roaring: faster, wider, and it draws harder at the base.
playful: { vigour: 1.35, entrain: 1.05, sway: 15, glow: 11, life: 2.2 },
};
/** Reference box height the tuned numbers were measured at. */
const REFERENCE = 226;
/** Plume half-width at the base, as a fraction of the height. */
const NECK = 0.115;
/** How fast the plume widens with height. Dimensionless. */
const SPREAD = 0.42;
/** Height over which the column stops drawing air in. */
const ENTRAIN_HEIGHT = 0.257;
/** Volume flux constant. Set so the axis rises at 0.72 heights a second. */
const FLUX = 0.0822;
const DEFAULT_COLORS = ["#5E1B08", "#B3450D", "#E8801F", "#FFD08A"];
/** `#RRGGBB` plus an alpha, since gradient stops need rgba. */
function withAlpha(hex: string, alpha: number) {
const value = hex.replace("#", "");
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
type Ember = {
x: number;
/** Height above the fire bed, in px. */
h: number;
heat: number;
life: number;
scale: number;
phase: number;
};
export default function CampfireEmbers({
variant = "default",
count = 150,
colors = DEFAULT_COLORS,
onEmberOut,
}: CampfireEmbersProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The parent's callback is read through a ref so an inline arrow can't
// rebuild the fire on every render, and the ref is written in an
// effect rather than during render.
const outRef = useRef(onEmberOut);
useEffect(() => {
outRef.current = onEmberOut;
}, [onEmberOut]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const wanted = Math.max(12, Math.round(count));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// One glow per colour stop, drawn once. A tight core inside a wide
// soft halo is what reads as burning rather than as a coloured disc.
const sprites = colors.map((tone) => {
const sprite = document.createElement("canvas");
const size = 48;
sprite.width = size;
sprite.height = size;
const paint = sprite.getContext("2d");
if (paint) {
const half = size / 2;
const glow = paint.createRadialGradient(half, half, 0, half, half, half);
glow.addColorStop(0, withAlpha(tone, 1));
glow.addColorStop(0.16, withAlpha(tone, 0.8));
glow.addColorStop(0.45, withAlpha(tone, 0.16));
glow.addColorStop(1, withAlpha(tone, 0));
paint.fillStyle = glow;
paint.fillRect(0, 0, size, size);
}
return sprite;
});
let width = 0;
let height = 0;
let scale = 1;
let baseY = 0;
let centreX = 0;
let neck = 26;
let entrainHeight = 58;
let flux = 4200;
let embers: Ember[] = [];
const random = (min: number, max: number) => min + Math.random() * (max - min);
const layout = () => {
const rect = canvas.getBoundingClientRect();
const 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);
// Two embers crossing should add their light, not clip each other.
context.globalCompositeOperation = "lighter";
scale = height / REFERENCE;
baseY = height * 0.95;
centreX = width / 2;
neck = NECK * height;
entrainHeight = ENTRAIN_HEIGHT * height;
// Flux goes as height squared, so the rise speed goes as height and
// the time to cross the frame is the same at any size.
flux = FLUX * height * height * config.vigour;
};
const spawn = (initial: boolean): Ember => {
// Most embers are born in the fire; a quarter start out at the
// sides, which is where the column's inflow is visible.
const wide = Math.random() < 0.25;
return {
x: wide
? centreX + random(-1, 1) * width * 0.3
: centreX + random(-neck, neck) * 0.85,
h: initial ? random(0, height * 0.9) : wide ? random(0, height * 0.16) : random(0, height * 0.05),
heat: initial ? Math.random() : wide ? random(0.45, 0.85) : 1,
life: config.life * random(0.7, 1.45),
scale: random(0.65, 1.4),
phase: random(0, Math.PI * 2),
};
};
/**
* The mean flow, straight off the stream function
* psi = -Q(h) * tanh((x - axis(h)) / w(h)). Velocity is its
* cross-derivative, which is why the divergence is zero by
* construction rather than by tuning.
*/
const flowAt = (x: number, h: number, time: number) => {
const w = neck + SPREAD * h;
const waveNumber = 0.018 / scale;
const lean = config.sway * scale;
const axis = centreX + lean * Math.sin(time * 0.6 - h * waveNumber);
const axisSlope = -lean * waveNumber * Math.cos(time * 0.6 - h * waveNumber);
const u = (x - axis) / w;
const cosh = Math.cosh(u);
const sech2 = 1 / (cosh * cosh);
const decay = Math.exp(-h / entrainHeight);
const q = flux * (1 + config.entrain * (1 - decay));
const qSlope = ((flux * config.entrain) / entrainHeight) * decay;
return {
up: (q * sech2) / w,
across:
-qSlope * Math.tanh(u) +
q * sech2 * (axisSlope / w + ((x - axis) * SPREAD) / (w * w)),
core: sech2,
};
};
const step = (delta: number, time: number) => {
for (let index = 0; index < embers.length; index++) {
const ember = embers[index];
const flow = flowAt(ember.x, ember.h, time);
ember.x += flow.across * delta;
ember.h += flow.up * delta;
// Turbulence, on top of the mean flow and strongest away from
// the core where the flow itself has nothing left to say.
ember.x +=
Math.sin(time * 2.3 + ember.phase) * 26 * scale * (1 - flow.core) * delta;
ember.heat -= delta / ember.life;
if (
ember.heat <= 0 ||
ember.h > height ||
ember.x < -20 ||
ember.x > width + 20
) {
outRef.current?.();
embers[index] = spawn(false);
}
}
};
const render = () => {
context.clearRect(0, 0, width, height);
// The fire bed, so the column has a visible source.
const bed = context.createRadialGradient(
centreX,
baseY,
0,
centreX,
baseY,
neck * 2.6
);
bed.addColorStop(0, withAlpha(colors[colors.length - 1], 0.42));
bed.addColorStop(0.35, withAlpha(colors[Math.max(0, colors.length - 2)], 0.24));
bed.addColorStop(1, withAlpha(colors[0], 0));
context.fillStyle = bed;
context.fillRect(centreX - neck * 3, baseY - neck * 3, neck * 6, neck * 4);
for (const ember of embers) {
const heat = Math.max(0, Math.min(1, ember.heat));
const sprite = sprites[Math.min(sprites.length - 1, Math.floor(heat * sprites.length))];
const radius = config.glow * scale * ember.scale * (0.34 + heat * 0.75);
context.globalAlpha = 0.16 + heat * 0.72;
context.drawImage(
sprite,
ember.x - radius,
baseY - ember.h - radius,
radius * 2,
radius * 2
);
}
context.globalAlpha = 1;
};
layout();
embers = Array.from({ length: wanted }, () => spawn(true));
// Run the column forward before the first frame, so it opens with a
// formed plume rather than a puff leaving the ground.
for (let index = 0; index < 180; index++) step(1 / 60, index / 60);
// Reduced motion: the same column, taken further and then held. The
// shape is the subject — a tight bright core, a necked base, a
// broad slow crown — and every part of it is in one frame.
if (reduced) {
for (let index = 0; index < 240; index++) step(1 / 60, 3 + index / 60);
render();
const onResizeStill = () => {
layout();
embers = Array.from({ length: wanted }, () => spawn(true));
for (let index = 0; index < 420; index++) step(1 / 60, index / 60);
render();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 3;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
step(delta, elapsed);
render();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
layout();
embers = Array.from({ length: wanted }, () => spawn(true));
for (let index = 0; index < 180; index++) step(1 / 60, index / 60);
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, colors]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
A warm, watchable background for a focus session, a night mode, a story or a screen that should feel like somewhere rather than something. There is no per-ember behaviour in it at all — there is one velocity field, and it is defined through a stream function rather than by writing the two velocity components down, so the flow is exactly divergence-free by construction. Checked across the whole frame, the divergence measures 4e-5 against terms of order 4, which is the finite difference's own error rather than a physical residual. That single property produces the shape: a column that speeds up has to pull air in from the sides to feed itself and a column that widens has to slow down, both from the same scalar instead of three rules that have to be kept consistent by hand. Advecting embers through it, the plume necks in from 58px to 18px in the first 20px of rise and opens out to 44px by the top — a candle silhouette nobody drew. The rise is 160px/s on the axis and 8px/s sixty pixels out, so an ember that wanders off-centre stops climbing and is carried around instead, which is the slow tumble at the edges for free. Author 'up, faster in the middle' on its own and the column frays within a second.