Pixel Fire
The classic demo-scene fire. The bottom row of chunky pixels burns white-hot, and every pixel above copies the one below it, a shade cooler and nudged sideways at random, so flames lick and flicker up the box. Move the pointer over it, or touch it, to stoke a fresh flame at that spot.
Move or drag to stoke the fire
How to use it
- Paste the snippet just before
</head>. - Add
data-pixel-fireto a box with a height and a black background.
Options
Add any of these attributes to the same element.
data-pixel | Size of one fire pixel in px. Default 4. |
data-palette | classic, purple or blue. Default classic. |
data-height | How tall the flames reach, 0.3 to 2. Default 1. |
data-wind | Sideways drift, from -1 (left) to 1 (right). Default 0. |
data-fps | Fire updates per second. Default 30, for that old-school judder. |
data-flare | Now and then, put the fire out and let it roar back up. |
data-pointer="false" | Ignore the pointer. |
The palette is the 37 colours used by the fire in the PlayStation port of Doom. The canvas is drawn one dot per fire pixel and scaled up with image-rendering: pixelated, so it stays crisp and costs very little. It’s paused when off screen, and with reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pixel Fire: the classic demo-scene fire. The bottom row of chunky
// pixels burns white-hot, and each pixel above copies the one below it,
// a shade cooler and nudged sideways at random, so flames lick and
// flicker up the box. Move the pointer over it, or touch it, to stoke a
// fresh flame at that spot.
// Usage: <div data-pixel-fire style="height:360px;background:#070707"></div>
// Optional: data-pixel (size of one fire pixel in px, default 4),
// data-palette ("classic", "purple" or "blue", default classic),
// data-height (how tall the flames reach, 0.3 to 2, default 1),
// data-wind (sideways drift, -1 to 1, default 0), data-fps (fire updates
// per second, default 30), data-pointer="false" (ignore the pointer),
// data-flare (flare up and die down now and then).
var DEFAULTS = { pixel: 4, palette: 'classic', height: 1, wind: 0, fps: 30 };
// The original 37-step palette, from black through red and orange to white.
var CLASSIC = [
7, 7, 7, 31, 7, 7, 47, 15, 7, 71, 15, 7, 87, 23, 7, 103, 31, 7, 119, 31, 7, 143, 39, 7, 159, 47, 7,
175, 63, 7, 191, 71, 7, 199, 71, 7, 223, 79, 7, 223, 87, 7, 223, 87, 7, 215, 95, 7, 215, 95, 7,
215, 103, 15, 207, 111, 15, 207, 119, 15, 207, 127, 15, 207, 135, 23, 199, 135, 23, 199, 143, 23,
199, 151, 31, 191, 159, 31, 191, 159, 31, 191, 167, 39, 191, 167, 39, 191, 175, 47, 183, 175, 47,
183, 183, 47, 183, 183, 55, 207, 207, 111, 223, 223, 159, 239, 239, 199, 255, 255, 255
];
var css =
'[data-pixel-fire]{position:relative;isolation:isolate;overflow:hidden}' +
'.pxf-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;image-rendering:pixelated}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// The palette as packed RGBA, with the coolest steps fading to transparent
// so the box's own background shows through.
function palette(name) {
var out = [];
for (var i = 0; i < 37; i++) {
var r = CLASSIC[i * 3], g = CLASSIC[i * 3 + 1], b = CLASSIC[i * 3 + 2];
if (name === 'purple') { var pr = (r + g) / 2, pg = g * 0.6 + b * 0.4; g = pg; b = r; r = pr; }
else if (name === 'blue') { var br = b, bb = r; g = g * 0.8 + b * 0.2; r = br; b = bb; }
var a = i === 0 ? 0 : Math.min(255, i * 40);
out.push([Math.round(r), Math.round(g), Math.round(b), a]);
}
return out;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'pxf-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var pixel = Math.max(1, +d.pixel || DEFAULTS.pixel);
var pal = palette(d.palette || DEFAULTS.palette);
var tall = Math.max(0.3, Math.min(2, d.height !== undefined ? +d.height : DEFAULTS.height));
var wind = Math.max(-1, Math.min(1, +d.wind || DEFAULTS.wind));
var stepMs = 1000 / (+d.fps || DEFAULTS.fps);
var usePointer = d.pointer !== 'false';
var flare = d.flare !== undefined && d.flare !== 'false';
var W = 0, H = 0, fire = null, img = null, data = null, t = 0;
var off = document.createElement('canvas'), octx = off.getContext('2d');
var pointer = null, heat = 36;
function resize() {
var nw = Math.max(8, Math.ceil(el.clientWidth / pixel)), nh = Math.max(8, Math.ceil(el.clientHeight / pixel));
if (nw === W && nh === H) return false;
W = nw; H = nh;
off.width = W; off.height = H;
// The canvas works at fire-pixel size and is stretched up, crisp.
canvas.width = W; canvas.height = H;
fire = new Uint8Array(W * H);
img = octx.createImageData(W, H);
data = img.data;
light();
return true;
}
function light() {
for (var x = 0; x < W; x++) fire[(H - 1) * W + x] = heat;
}
// How likely a pixel is to cool by one step as it rises: the flame's
// height scales with the box and with data-height.
function coolChance() {
var reach = H * 0.62 * tall;
return Math.max(0.05, Math.min(1, 36 / reach));
}
function spread() {
var base = coolChance(), dir = wind < 0 ? -1 : 1, lean = Math.abs(wind) * 0.6;
for (var x = 0; x < W; x++) {
// Some columns cool more slowly than others, and the pattern drifts,
// so the fire rises in tongues rather than one flat band.
var n = Math.sin(x * 0.11 + t * 1.3) * 0.5 + Math.sin(x * 0.27 - t * 2.1) * 0.3 + Math.sin(x * 0.05 + t * 0.6) * 0.2;
var cool = base * (1 - n * 0.45);
for (var y = 1; y < H; y++) {
var src = y * W + x, v = fire[src];
if (v === 0) { fire[src - W] = 0; continue; }
var dx = Math.floor(Math.random() * 3) - 1;
if (Math.random() < lean) dx -= dir;
var dst = src - W - dx;
if (dst < 0) dst += W;
var c = Math.random() < cool ? 1 : 0;
fire[dst] = v - c > 0 ? v - c : 0;
}
}
// A stoked flame under the pointer.
if (pointer) {
var px = Math.round(pointer.x / pixel), py = Math.round(pointer.y / pixel);
for (var i = -3; i <= 3; i++) {
for (var j = -2; j <= 2; j++) {
var xx = px + i, yy = py + j;
if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue;
if (Math.abs(i) + Math.abs(j) < 4) fire[yy * W + xx] = Math.max(fire[yy * W + xx], 36 - Math.abs(i) * 3);
}
}
}
}
function render() {
for (var i = 0, n = W * H; i < n; i++) {
var c = pal[fire[i]], k = i * 4;
data[k] = c[0]; data[k + 1] = c[1]; data[k + 2] = c[2]; data[k + 3] = c[3];
}
ctx.putImageData(img, 0, 0);
}
resize();
for (var q = 0; q < H * 1.4; q++) spread();
render();
if (window.ResizeObserver) new ResizeObserver(function () {
if (resize()) { for (var q2 = 0; q2 < H * 1.4; q2++) spread(); render(); }
}).observe(el);
if (reduced()) return;
if (usePointer) {
var at = function (e) {
var b = el.getBoundingClientRect();
pointer = { x: e.clientX - b.left, y: e.clientY - b.top };
};
el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) at(e); });
el.addEventListener('pointerdown', at);
el.addEventListener('pointerleave', function () { pointer = null; });
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; });
el.addEventListener('pointercancel', function () { pointer = null; });
}
var last = 0, acc = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(100, now - last) : 16;
last = now;
t += dt / 1000;
if (flare) {
// Die down to embers and roar back up, on a slow cycle.
heat = Math.sin(t * 0.55) < -0.6 ? 0 : 36;
light();
}
acc += dt;
var ran = false;
while (acc >= stepMs) { acc -= stepMs; spread(); ran = true; }
if (ran) render();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-pixel-fire]').forEach(function (el) {
if (el._pxf) return;
el._pxf = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-pixel-fire style="height: 360px; background: #070707"></div>
<!-- purple, blowing right: -->
<div data-pixel-fire data-palette="purple" data-wind="0.5" style="height: 360px; background: #070707"></div>