← All particles

Ember Rise

Embers lifting from below, cooling from white through amber to nothing.

ambientwarmcozyenergetic70 particles · light · canvas-2d · automatic · looping
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.

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

/**
 * Vibary · Ember Rise
 *
 * Embers lifting off something that has been burning for a while. The
 * whole effect hangs on one number: how much heat an ember has left.
 * It sets the color — white, then amber, then a dull red, then nothing —
 * and it also sets the size and the rise speed, because hot air is what
 * carries an ember up. So an ember slows as it dims and dies partway up
 * the frame. Embers that rise at a constant speed and fade at a constant
 * color read as sparks in a game; cooling is deceleration.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type EmberRiseProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Embers alive at once. Overrides the variant's density. */
  count?: number;
  /**
   * Where they come from, as a fraction of the width: 1 is the whole
   * bottom edge, 0.4 is a fire pit in the middle.
   */
  spread?: number;
};

type VariantConfig = {
  /** Embers alive at this setting. */
  count: number;
  /** Rise speed in px per second at full heat. */
  rise: number;
  /** Sideways wander amplitude in px per second. */
  wander: number;
  /** Core radius in px at full heat. */
  size: number;
  /** Seconds an ember stays alight, before per-ember variation. */
  life: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // The last of a fire: a few embers, low and slow.
  subtle: { count: 46, rise: 34, wander: 5, size: 1.5, life: 3.4 },
  // A steady bed of coals. All-purpose.
  default: { count: 70, rise: 52, wander: 9, size: 1.9, life: 3 },
  // Someone put another log on: faster, brighter, higher.
  playful: { count: 96, rise: 76, wander: 15, size: 2.3, life: 2.5 },
};

type Ember = {
  x: number;
  y: number;
  /** Seconds of heat left; the whole look is a function of this. */
  heat: number;
  span: number;
  size: number;
  drift: number;
  phase: number;
  wanderRate: number;
};

/**
 * A cooling ramp, coldest first. Real embers walk down the blackbody
 * curve; four stops is enough to read as that rather than as a hue
 * rotation, which always looks like a color picker.
 */
const RAMP: [number, number, number][] = [
  [138, 34, 16],
  [232, 108, 38],
  [255, 186, 92],
  [255, 244, 218],
];

/** Heat is quantized into this many cached sprites — see below. */
const HEAT_STEPS = 12;
/** Sprite radius in CSS px; embers are drawn scaled down from it. */
const SPRITE_RADIUS = 22;

export default function EmberRise({
  variant = "default",
  count,
  spread = 1,
}: EmberRiseProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  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 heatColor = (heat: number) => {
      const scaled = Math.max(0, Math.min(1, heat)) * (RAMP.length - 1);
      const low = Math.floor(scaled);
      const high = Math.min(RAMP.length - 1, low + 1);
      const t = scaled - low;
      const from = RAMP[low];
      const to = RAMP[high];
      return `rgb(${Math.round(from[0] + (to[0] - from[0]) * t)}, ${Math.round(
        from[1] + (to[1] - from[1]) * t
      )}, ${Math.round(from[2] + (to[2] - from[2]) * t)})`;
    };

    /**
     * One glow sprite per heat step, tinted by painting the alpha
     * profile first and flooding it through `source-in`. Quantizing to
     * twelve steps is invisible on a ramp this smooth, and it turns a
     * per-ember gradient rebuild into a single drawImage.
     */
    let sprites: HTMLCanvasElement[] = [];

    const buildSprites = () => {
      sprites = [];
      for (let step = 0; step < HEAT_STEPS; step++) {
        const heat = step / (HEAT_STEPS - 1);
        const sprite = document.createElement("canvas");
        const box = Math.ceil(SPRITE_RADIUS * 2 * ratio);
        sprite.width = box;
        sprite.height = box;
        const paint = sprite.getContext("2d");
        if (!paint) continue;
        paint.setTransform(ratio, 0, 0, ratio, 0, 0);
        const gradient = paint.createRadialGradient(
          SPRITE_RADIUS,
          SPRITE_RADIUS,
          0,
          SPRITE_RADIUS,
          SPRITE_RADIUS,
          SPRITE_RADIUS
        );
        gradient.addColorStop(0, "rgba(0, 0, 0, 1)");
        gradient.addColorStop(0.22, "rgba(0, 0, 0, 0.7)");
        gradient.addColorStop(0.5, "rgba(0, 0, 0, 0.18)");
        gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
        paint.fillStyle = gradient;
        paint.fillRect(0, 0, SPRITE_RADIUS * 2, SPRITE_RADIUS * 2);
        paint.globalCompositeOperation = "source-in";
        paint.fillStyle = heatColor(heat);
        paint.fillRect(0, 0, SPRITE_RADIUS * 2, SPRITE_RADIUS * 2);
        sprites.push(sprite);
      }
    };

    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);
      buildSprites();
    };
    resize();

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const spawn = (initial: boolean): Ember => {
      const span = config.life * random(0.7, 1.3);
      const margin = (1 - Math.max(0.05, Math.min(1, spread))) / 2;
      return {
        x: random(width * margin, width * (1 - margin)),
        y: random(height * 0.9, height * 1.04),
        // On the first fill, start part-cooled so the column is already
        // populated rather than igniting all at once.
        heat: initial ? random(0.15, 1) : 1,
        span,
        size: config.size * random(0.7, 1.25),
        drift: random(-0.4, 0.4),
        phase: random(0, Math.PI * 2),
        wanderRate: random(0.6, 1.4),
      };
    };

    let embers = Array.from({ length: total }, () => spawn(true));

    const render = () => {
      context.clearRect(0, 0, width, height);
      for (const ember of embers) {
        const heat = Math.max(0, ember.heat);
        const step = Math.min(HEAT_STEPS - 1, Math.round(heat * (HEAT_STEPS - 1)));
        const sprite = sprites[step];
        if (!sprite) continue;
        // Fades in over the first moments off the coals, then out as it
        // cools — both ends of the life, so nothing pops into existence.
        const alpha =
          Math.min(1, (1 - heat) * 14) * Math.min(1, heat * 2.6) * 0.9;
        if (alpha <= 0.01) continue;
        const radius = ember.size * (0.45 + heat * 0.55) * 3.4;
        context.globalAlpha = alpha;
        context.drawImage(
          sprite,
          ember.x - radius,
          ember.y - radius,
          radius * 2,
          radius * 2
        );
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: one still frame. Embers at assorted heats read as
    // a fire that is going; the rising is not carrying any information.
    if (reduced) {
      render();
      const onResizeStill = () => {
        resize();
        embers = Array.from({ length: total }, () => spawn(true));
        render();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

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

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

      for (const ember of embers) {
        ember.heat -= delta / ember.span;
        // The deceleration: lift is proportional to what is left burning.
        ember.y -= config.rise * (0.3 + Math.max(0, ember.heat) * 0.7) * delta;
        ember.x +=
          (Math.sin(elapsed * ember.wanderRate + ember.phase) + ember.drift) *
          config.wander *
          delta;
        if (ember.heat <= 0 || ember.y < -30) Object.assign(ember, spawn(false));
      }

      render();
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      embers = Array.from({ length: total }, () => spawn(true));
    };
    window.addEventListener("resize", onResize);

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

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

About this effect

A warm ambient field for a screen that should feel like it is running rather than waiting — a focus timer, a long render, a session in progress. One number carries it: the heat an ember has left. Heat sets the color as it walks down a four-stop cooling ramp, and it also sets the size and the rise speed, because the lift comes from the hot air the ember is making. So each ember slows as it dims and dies partway up the frame instead of sailing out of the top. Glow is drawn from twelve cached sprites, one per heat step, so the whole field costs one blit per ember.

Focus timerLong-running jobFireside empty stateStreak or momentum card

Related effects