Fire Text
Flames flicker up from the letters and the odd spark drifts off the top. The fire is drawn with soft glowing particles behind text filled with a white-hot gradient, so the words look like they’re burning.
On fire
How to use it
- Paste the snippet just before
</head>. - Add
data-fire-textto a heading on a dark background, with a little room above it for the flames.
Options
Add any of these attributes to the same element.
data-height | How tall the flames reach above the letters. Default 1 em. |
data-intensity | How many flames, from 0.2 to 3. Default 1. |
data-sparks | Sparks per second. Default 6; 0 for none. |
data-keep-color | Keep the text’s own colour instead of the fire gradient. |
The flames are added light, so they glow on dark backgrounds and fade out on light ones. They only burn while the text is on screen; with reduced motion turned on just the hot text shows.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Fire Text: flickering flames lick up from the letters, with the odd spark
// drifting off the top. The flames are soft glowing particles drawn on a
// canvas behind the text, which is filled with a hot gradient. Looks best
// on a dark background.
// Usage: <h1 data-fire-text>Hot stuff</h1>
// Optional: data-height (how tall the flames reach above the letters, in
// em; default 1), data-intensity (how many flames, 0.2 to 3; default 1),
// data-sparks (sparks per second; default 6, 0 for none), data-keep-color
// (keep the text's own colour instead of the fire gradient).
var DEFAULTS = { height: 1, intensity: 1, sparks: 6 };
var css =
'.fir-wrap{position:relative;display:inline-block}' +
'.fir-canvas{position:absolute;left:-.4em;right:-.4em;top:-1.2em;bottom:-.1em;width:calc(100% + .8em);height:calc(100% + 1.3em);pointer-events:none}' +
'.fir-text{position:relative;display:inline-block}' +
'.fir-hot{background:linear-gradient(0deg,#ff3d00 0%,#ff8a00 40%,#ffd23f 75%,#fff6d0 100%);-webkit-background-clip:text;background-clip:text;color:transparent}' +
'.fir-word{display:inline-block;white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Soft round glows in flame colours, drawn once and stamped many times.
var sprites = null;
function makeSprites() {
if (sprites) return sprites;
sprites = [[255, 240, 190], [255, 170, 40], [255, 90, 10], [190, 30, 5]].map(function (c) {
var cv = document.createElement('canvas');
cv.width = cv.height = 64;
var g = cv.getContext('2d');
var grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(' + c + ',1)');
grad.addColorStop(0.4, 'rgba(' + c + ',.55)');
grad.addColorStop(1, 'rgba(' + c + ',0)');
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
return cv;
});
return sprites;
}
function setup(el) {
var d = el.dataset;
var height = d.height !== undefined ? +d.height : DEFAULTS.height;
var intensity = Math.max(0.2, Math.min(3, d.intensity !== undefined ? +d.intensity : DEFAULTS.intensity));
var sparkRate = d.sparks !== undefined ? +d.sparks : DEFAULTS.sparks;
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var wrap = document.createElement('span');
wrap.className = 'fir-wrap';
wrap.setAttribute('aria-hidden', 'true');
var canvas = document.createElement('canvas');
canvas.className = 'fir-canvas';
canvas.style.top = -(0.2 + height * 1.1).toFixed(2) + 'em';
canvas.style.height = 'calc(100% + ' + (0.3 + height * 1.1).toFixed(2) + 'em)';
var label = document.createElement('span');
label.className = 'fir-text';
if (d.keepColor === undefined || d.keepColor === 'false') label.classList.add('fir-hot');
var chars = [];
text.split(' ').forEach(function (w, i) {
if (i) label.appendChild(document.createTextNode(' '));
var word = document.createElement('span');
word.className = 'fir-word';
Array.from(w).forEach(function (c) {
var s = document.createElement('span');
s.textContent = c;
word.appendChild(s);
if (c.trim()) chars.push({ el: s });
});
label.appendChild(word);
});
wrap.appendChild(canvas);
wrap.appendChild(label);
el.appendChild(wrap);
if (reduced() || !chars.length) return;
var spr = makeSprites();
var ctx = canvas.getContext('2d');
var W = 0, H = 0, fs = 16, totalW = 1;
var parts = [];
function resize() {
var dpr = Math.min(2, window.devicePixelRatio || 1);
W = canvas.clientWidth; H = canvas.clientHeight;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
fs = parseFloat(getComputedStyle(el).fontSize);
// Each letter's box, in canvas coordinates.
var cr = canvas.getBoundingClientRect();
totalW = 0;
chars.forEach(function (c) {
var r = c.el.getBoundingClientRect();
c.x = r.left - cr.left; c.y = r.top - cr.top; c.w = r.width; c.h = r.height;
totalW += r.width;
});
}
resize();
new ResizeObserver(resize).observe(el);
function pick() {
var t = Math.random() * totalW;
for (var i = 0; i < chars.length; i++) { t -= chars[i].w; if (t <= 0) return chars[i]; }
return chars[chars.length - 1];
}
function spawn(spark) {
var c = pick();
var life = spark ? 900 + Math.random() * 900 : (420 + Math.random() * 420) * (0.6 + height * 0.4);
parts.push({
x: c.x + c.w * (0.12 + Math.random() * 0.76),
y: c.y + c.h * (spark ? 0.3 : 0.22 + Math.random() * 0.45),
vx: (Math.random() - 0.5) * fs * 0.3,
vy: -fs * (spark ? 1.6 + Math.random() : (1.1 + Math.random() * 1.2) * height),
size: spark ? fs * 0.05 : fs * (0.16 + Math.random() * 0.18),
life: life, age: 0, seed: Math.random() * 6.28, spark: spark
});
}
var prev = 0, clock = 0, owed = 0, owedSparks = 0;
var ticker = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = prev ? Math.min(50, now - prev) : 16;
prev = now;
clock += dt;
// More text, more flames; the rate wavers so the fire breathes.
var rate = totalW / fs * 9 * intensity * (0.8 + 0.35 * Math.sin(clock * 0.004) * Math.sin(clock * 0.0017));
owed += rate * dt / 1000 * 9;
while (owed >= 1) { spawn(false); owed--; }
owedSparks += sparkRate * dt / 1000;
while (owedSparks >= 1) { spawn(true); owedSparks--; }
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
for (var i = parts.length - 1; i >= 0; i--) {
var p = parts[i];
p.age += dt;
var r = p.age / p.life;
if (r >= 1) { parts.splice(i, 1); continue; }
var s = dt / 1000;
// A little sideways flutter, and flames speed up as they rise.
p.vx += Math.sin(clock * 0.012 + p.seed) * fs * 0.9 * s;
p.x += p.vx * s;
p.y += p.vy * s * (p.spark ? 1 : 0.7 + r);
var size, tall, img, a;
if (p.spark) {
size = tall = p.size * 3;
img = spr[r < 0.5 ? 0 : 1];
a = (1 - r) * (0.6 + 0.4 * Math.sin(clock * 0.05 + p.seed));
} else {
// Tall, narrow blobs that shrink as they rise read as tongues of flame.
size = p.size * (1 - r * 0.7) * 2;
tall = size * 1.9;
img = spr[r < 0.1 ? 0 : r < 0.4 ? 1 : r < 0.7 ? 2 : 3];
a = Math.pow(1 - r, 1.2) * 0.62;
}
ctx.globalAlpha = Math.max(0, a);
ctx.drawImage(img, p.x - size / 2, p.y - tall / 2, size, tall);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
else ticker.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-fire-text]').forEach(function (el) {
if (el._fir) return;
el._fir = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-fire-text>On fire</h1>
<!-- a bigger blaze: -->
<h1 data-fire-text data-height="1.5" data-intensity="1.6">On fire</h1>