Cinder
A falling-ember background: a parallax field of hot embers, each an incandescent core in an additive-glow halo, stretched into a soft motion-blur streak as it drifts and flickers down. Near embers big/bright/fast, far ones dim/slow. 2D canvas with additive blending, seeded PRNG for deterministic spawn layout, honors prefers-reduced-motion.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface CinderProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of embers in the field. Clamped 6–120. Default 28. */
density?: number;
/**
* Fall angle in degrees, measured from vertical (0 = straight down,
* positive tilts right). Cinder falls steep by default. Default 18.
*/
angle?: number;
/** Fall speed multiplier. 1 = default, 2 = twice as fast. Default 1. */
speed?: number;
/** Ember colors, hottest → coolest, mapped across the depth field. Defaults to a heat palette. */
colors?: string[];
/** Freeze the shower at its current frame. */
paused?: boolean;
/** Deterministic seed for spawn layout — same seed always renders the same field. */
seed?: number;
/** devicePixelRatio ceiling. Default 2. */
dpr?: number;
}
interface Ember {
x: number; // device px
y: number; // device px
z: number; // depth 0 (far) → 1 (near)
size: number; // glow sprite draw size, device px
vy: number; // fall speed, device px/s (before global speed)
spriteIndex: number; // palette index
brightness: number;
driftAmp: number; // horizontal sway amplitude, device px
driftFreq: number;
driftPhase: number;
flickerFreq: number;
flickerPhase: number;
stretch: number; // motion-blur elongation along travel
}
/** Deterministic mulberry32 PRNG — same seed always produces the same sequence. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const DEFAULT_COLORS = ["#ffd8a8", "#ff6b35", "#c1121f"];
// Warm near-black bed the embers glow against.
const BG: [number, number, number] = [10, 7, 6];
/** Normalize any CSS color string to [r,g,b] using a throwaway 1×1 context. */
function colorToRgb(ctx: CanvasRenderingContext2D, color: string): [number, number, number] {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const d = ctx.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2]];
}
/**
* Pre-render one soft radial-glow sprite per palette color: a near-white hot
* core fading through the color to a transparent halo. Drawn additively so
* overlapping embers bloom. Cheap: built once, then blitted (scaled/rotated)
* per ember instead of rebuilding a gradient every frame.
*/
function makeSprite(rgb: [number, number, number], scale: number): HTMLCanvasElement {
const D = Math.max(24, Math.round(72 * scale));
const c = document.createElement("canvas");
c.width = D;
c.height = D;
const g = c.getContext("2d")!;
const cx = D / 2;
const [r, gg, b] = rgb;
// Hot core: mix heavily toward white so the ember reads as an incandescent point.
const cr = Math.round(r * 0.25 + 255 * 0.75);
const cg = Math.round(gg * 0.25 + 255 * 0.75);
const cb = Math.round(b * 0.25 + 255 * 0.75);
const grad = g.createRadialGradient(cx, cx, 0, cx, cx, cx);
grad.addColorStop(0, `rgba(${cr},${cg},${cb},1)`);
grad.addColorStop(0.16, `rgba(${r},${gg},${b},0.85)`);
grad.addColorStop(0.42, `rgba(${r},${gg},${b},0.22)`);
grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, D, D);
return c;
}
function buildField(density: number, seed: number, w: number, h: number, scale: number, paletteLen: number): Ember[] {
const count = Math.min(120, Math.max(6, Math.round(density)));
const rand = mulberry32(seed);
const embers: Ember[] = [];
for (let i = 0; i < count; i++) {
// Skew depth toward "far" so the field has many small dim embers behind a
// few large bright ones — natural perspective, not a uniform grid.
const z = rand() * rand();
embers.push(makeEmber(rand, z, w, h, scale, paletteLen, true));
}
return embers;
}
function makeEmber(
rand: () => number,
z: number,
w: number,
h: number,
scale: number,
paletteLen: number,
initial: boolean
): Ember {
const size = (14 + z * 46) * scale;
return {
// On first build spread across the full height so the field is full from
// frame one; on recycle spawn just above the top edge.
x: rand() * w,
y: initial ? rand() * h : -size - rand() * h * 0.3,
z,
size,
vy: (34 + z * 210) * scale,
// Hottest color (index 0) for the nearest embers, coolest for the far ones.
spriteIndex: Math.min(paletteLen - 1, Math.floor((1 - z) * paletteLen)),
brightness: 0.34 + z * 0.5,
driftAmp: (6 + z * 22) * scale,
driftFreq: 0.4 + rand() * 0.9,
driftPhase: rand() * Math.PI * 2,
flickerFreq: 4 + rand() * 7,
flickerPhase: rand() * Math.PI * 2,
stretch: 1.3 + z * 2.2,
};
}
interface Internals {
ctx: CanvasRenderingContext2D;
w: number;
h: number;
scale: number;
sprites: HTMLCanvasElement[];
embers: Ember[];
rand: () => number;
last: number;
angleRad: number;
}
function paintBackground(it: Internals) {
it.ctx.globalCompositeOperation = "source-over";
it.ctx.globalAlpha = 1;
it.ctx.fillStyle = `rgb(${BG[0]},${BG[1]},${BG[2]})`;
it.ctx.fillRect(0, 0, it.w, it.h);
}
/** Draw a single ember: additive glow sprite, stretched along its travel. */
function drawEmber(it: Internals, e: Ember, t: number, animate: boolean) {
const flicker = animate ? 0.72 + 0.28 * Math.sin(e.flickerPhase + t * e.flickerFreq) : 0.9;
const alpha = Math.max(0, Math.min(1, e.brightness * flicker));
if (alpha <= 0.001) return;
const { ctx } = it;
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = alpha;
ctx.translate(e.x, e.y);
// Elongate the glow along the fall direction for a motion-blur streak.
ctx.rotate(it.angleRad);
const dw = e.size;
const dh = e.size * (animate ? e.stretch : 1);
ctx.drawImage(it.sprites[e.spriteIndex], -dw / 2, -dh / 2, dw, dh);
ctx.restore();
}
/** One animated frame: fade the previous frame (trails) then advance + draw. */
function tickFrame(it: Internals, dt: number, t: number, speedFactor: number) {
// Fade toward the bed instead of clearing → embers leave soft trails.
it.ctx.globalCompositeOperation = "source-over";
it.ctx.globalAlpha = 1;
it.ctx.fillStyle = `rgba(${BG[0]},${BG[1]},${BG[2]},0.2)`;
it.ctx.fillRect(0, 0, it.w, it.h);
const sinA = Math.sin(it.angleRad);
const cosA = Math.cos(it.angleRad);
const margin = 80 * it.scale;
for (const e of it.embers) {
const speed = e.vy * speedFactor;
const drift = e.driftAmp * e.driftFreq * Math.cos(e.driftPhase + t * e.driftFreq);
e.x += (speed * sinA + drift) * dt;
e.y += speed * cosA * dt;
if (e.y - e.size > it.h + margin || e.x < -margin || e.x > it.w + margin) {
const z = it.rand() * it.rand();
Object.assign(e, makeEmber(it.rand, z, it.w, it.h, it.scale, it.sprites.length, false));
}
drawEmber(it, e, t, true);
}
}
/** Static, handsome frame for paused / reduced-motion: embers at rest, no trails. */
function drawStatic(it: Internals) {
paintBackground(it);
for (const e of it.embers) drawEmber(it, e, 0, false);
}
/**
* Cinder — a falling-ember background. A parallax field of hot embers: each an
* incandescent core wrapped in an additive-glow halo, stretched into a soft
* motion-blur streak along its fall, swaying and flickering as it drops. Near
* embers are large, bright and fast; far ones dim and slow — real depth, not a
* uniform grid. 2D canvas with additive blending; seeded PRNG for deterministic
* spawn layout (no hydration mismatch), pauses offscreen, honors
* prefers-reduced-motion with a static frame of embers at rest.
*/
export function Cinder({
density = 28,
angle = 18,
speed = 1,
colors = DEFAULT_COLORS,
paused = false,
seed = 1,
dpr = 2,
className,
style,
...props
}: CinderProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const speedFactor = Math.max(speed, 0.01);
const angleRad = (angle * Math.PI) / 180;
const frozen = paused || reducedMotion;
const colorsKey = colors.join(",");
const tick = React.useCallback(
(elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
const dt = Math.min(elapsed - it.last, 0.05);
it.last = elapsed;
if (dt <= 0) return;
tickFrame(it, dt, elapsed, speedFactor);
},
[speedFactor]
);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });
// Set up canvas, sprites and the ember field; rebuild on size / palette / seed.
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const scale = Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, dpr);
const palette = colors.length ? colors : DEFAULT_COLORS;
const sprites = palette.map((c) => makeSprite(colorToRgb(ctx, c), scale));
const build = () => {
const w = Math.max(1, Math.round(container.clientWidth * scale));
const h = Math.max(1, Math.round(container.clientHeight * scale));
canvas.width = w;
canvas.height = h;
const it: Internals = {
ctx,
w,
h,
scale,
sprites,
embers: buildField(density, seed, w, h, scale, sprites.length),
rand: mulberry32(seed ^ 0x9e3779b9),
last: 0,
angleRad,
};
internalsRef.current = it;
// Paint one frame immediately so mount/resize never flashes empty.
paintBackground(it);
for (const e of it.embers) drawEmber(it, e, 0, !frozen);
};
const ro = new ResizeObserver(build);
ro.observe(container);
build();
return () => {
ro.disconnect();
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, seed, colorsKey, dpr, angleRad]);
// Keep angle live and re-render the static frame when frozen so prop tweaks show.
React.useEffect(() => {
const it = internalsRef.current;
if (!it) return;
it.angleRad = angleRad;
if (frozen) drawStatic(it);
}, [angleRad, frozen]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="cinder"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
style={style}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/cinderProps
| Prop | Type | Default | Description |
|---|---|---|---|
| density | number | 28 | Number of embers in the field. Clamped 6–120. |
| angle | number | 18 | Fall angle in degrees, measured from vertical (0 = straight down, positive tilts right). Cinder falls steep by default. |
| speed | number | 1 | Fall speed multiplier. 1 = default, 2 = twice as fast. |
| colors | string[] | a heat palette | Ember colors, hottest → coolest, mapped across the depth field. |
| paused | boolean | Freeze the shower at its current frame. | |
| seed | number | Deterministic seed for spawn layout — same seed always renders the same field. | |
| dpr | number | 2 | devicePixelRatio ceiling. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.