Burn-in Text
A glowing ember edge burns across the line, throwing off sparks, and leaves the text behind it, cooling from white-hot to its own colour.
Forged in fire
How to use it
- Paste the snippet just before
</head>. - Add
data-burn-in-textto a heading or short line of text, ideally on a dark background.
Options
Add any of these attributes to the same element.
data-color | The ember glow. Default #ff7a18. |
data-duration | Time for the edge to burn across. Default 1800 ms. |
data-sparks | false for no sparks. |
data-loop | Burn in, hold, fade and burn in again. |
data-hold | How long the text stays before fading, when looping. Default 1800 ms. |
data-delay | Wait before starting. Default 0 ms. |
The edge starts where the text starts, so a short, centred heading doesn’t wait for it to cross empty space.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Burn-in Text: a glowing ember edge burns across the line from left to
// right, throwing off sparks, and leaves the text behind it, cooling from
// white-hot to its own colour. Looks best on a dark background.
// Usage: <h1 data-burn-in-text>Forged in fire</h1>
// Optional: data-color (the ember glow, default #ff7a18), data-duration (ms
// to burn across, default 1800), data-sparks="false" (no sparks), data-loop
// (fade, then burn in again), data-hold (ms to stay before fading),
// data-delay (ms before starting).
var DEFAULTS = { color: '#ff7a18', duration: 1800, hold: 1800, delay: 0 };
var css =
'.brn-on{position:relative}' +
'.brn-base{display:block}' +
'.brn-hot{position:absolute;inset:0;pointer-events:none;color:#fff4d6}' +
'.brn-edge{position:absolute;left:0;top:0;pointer-events:none;mix-blend-mode:screen;opacity:0}' +
'.brn-spark{position:absolute;left:0;top:0;width:3px;height:3px;border-radius:50%;pointer-events:none;background:#ffe6a8}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
var count = 0;
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setup(el) {
var d = el.dataset, cs = getComputedStyle(el);
var text = el.textContent.replace(/\s+/g, ' ').trim();
var color = d.color || DEFAULTS.color;
el.setAttribute('aria-label', text);
el.textContent = '';
el.classList.add('brn-on');
var base = document.createElement('span');
base.className = 'brn-base';
base.setAttribute('aria-hidden', 'true');
base.textContent = text;
el.appendChild(base);
// The hot copy: the same text glowing, shown only in a band at the edge,
// and shimmering through a heat-haze filter.
var id = 'brn-filter-' + (++count);
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', '0');
svg.setAttribute('height', '0');
svg.style.position = 'absolute';
svg.innerHTML =
'<filter id="' + id + '" x="-10%" y="-30%" width="120%" height="160%">' +
'<feTurbulence type="fractalNoise" baseFrequency="0.05 0.12" numOctaves="2" seed="' + count + '" result="n"/>' +
'<feDisplacementMap in="SourceGraphic" in2="n" scale="6" xChannelSelector="R" yChannelSelector="G"/>' +
'</filter>';
el.appendChild(svg);
var hot = document.createElement('span');
hot.className = 'brn-hot';
hot.setAttribute('aria-hidden', 'true');
hot.textContent = text;
// Taller than the element, so the glow isn't cut off above and below.
var fs = parseFloat(cs.fontSize), pad = fs * 0.6;
hot.style.padding = (parseFloat(cs.paddingTop) + pad) + 'px ' + cs.paddingRight + ' ' +
(parseFloat(cs.paddingBottom) + pad) + 'px ' + cs.paddingLeft;
hot.style.inset = -pad + 'px 0';
hot.style.textShadow = '0 0 .08em ' + color + ',0 0 .25em ' + color + ',0 0 .6em ' + color;
hot.style.filter = 'url(#' + id + ')';
el.appendChild(hot);
// A soft line of light that rides along the burning edge.
var edge = document.createElement('span');
edge.className = 'brn-edge';
edge.setAttribute('aria-hidden', 'true');
edge.style.background = 'radial-gradient(closest-side,#fff1c4,' + color + ' 45%,transparent)';
el.appendChild(edge);
return {
base: base, hot: hot, edge: edge, color: color,
turb: svg.querySelector('feTurbulence'),
fs: fs
};
}
function mask(node, value) {
node.style.webkitMaskImage = node.style.maskImage = value;
}
// Where the text actually starts and ends inside the element, so a short,
// centred line doesn't wait for the edge to cross empty space.
function extent(el, s) {
var r = document.createRange();
r.selectNodeContents(s.base);
// Measured from the padding box, where the hot copy and the sparks sit.
var t = r.getBoundingClientRect(), e = el.getBoundingClientRect();
var left = e.left + el.clientLeft, top = e.top + el.clientTop;
var off = s.base.getBoundingClientRect().left - left;
return { from: t.left - left, to: t.right - left, top: t.top - top, bottom: t.bottom - top, off: off };
}
function spark(el, s, x, ext) {
var p = document.createElement('span');
p.className = 'brn-spark';
p.setAttribute('aria-hidden', 'true');
p.style.boxShadow = '0 0 6px 1px ' + s.color;
el.appendChild(p);
var y = ext.top + Math.random() * (ext.bottom - ext.top);
var rise = s.fs * anime.utils.random(0.25, 0.6, 2);
anime.animate(p, {
x: [x, x + s.fs * anime.utils.random(-0.15, 0.35, 2)],
y: [y, y - rise],
scale: [anime.utils.random(0.6, 1.3, 2), 0.2],
opacity: [1, 0],
duration: anime.utils.random(500, 900),
ease: 'out(2)',
onComplete: function () { p.remove(); }
});
}
function play(el) {
var d = el.dataset, s = el._brn;
var loop = d.loop !== undefined && d.loop !== 'false';
var sparks = d.sparks !== 'false';
var ext = extent(el, s);
var soft = s.fs * 0.12, band = s.fs * 1.4;
var state = { x: ext.from - soft };
var lastSpark = 0;
var eh = (ext.bottom - ext.top) * 1.15, ew = s.fs * 0.5;
s.edge.style.width = ew + 'px';
s.edge.style.height = eh + 'px';
function draw() {
var x = state.x;
var bx = x - ext.off;
mask(s.base, 'linear-gradient(90deg,#000 ' + (bx - soft) + 'px,transparent ' + bx + 'px)');
mask(s.hot, 'linear-gradient(90deg,transparent ' + (x - band) + 'px,rgba(0,0,0,.55) ' + (x - band * 0.35) +
'px,#000 ' + (x - soft * 0.5) + 'px,transparent ' + (x + soft * 0.6) + 'px)');
var glow = Math.max(0, Math.min(1, (ext.to + soft - x) / (s.fs * 0.6), (x - ext.from + soft) / (s.fs * 0.3)));
s.edge.style.opacity = (glow * (0.75 + Math.random() * 0.25)).toFixed(2);
s.edge.style.transform = 'translate(' + (x - ew / 2).toFixed(1) + 'px,' +
((ext.top + ext.bottom - eh) / 2).toFixed(1) + 'px)';
// Flicker the heat haze.
s.turb.setAttribute('seed', (Math.random() * 50) | 0);
var now = performance.now();
if (sparks && now - lastSpark > 45 && x < ext.to) {
lastSpark = now;
spark(el, s, x, ext);
}
}
s.base.style.opacity = '';
s.hot.style.opacity = '';
draw();
anime.animate(state, {
x: ext.to + band + soft,
duration: +d.duration || DEFAULTS.duration,
ease: 'inOut(1.3)',
onUpdate: draw,
onComplete: function () {
mask(s.base, '');
s.edge.style.opacity = 0;
mask(s.hot, 'linear-gradient(transparent,transparent)');
if (!loop) return;
setTimeout(function () {
anime.animate(s.base, {
opacity: 0, duration: 700, ease: 'in(2)',
onComplete: function () { setTimeout(function () { play(el); }, 300); }
});
}, +d.hold || DEFAULTS.hold);
}
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
var el = e.target;
setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-burn-in-text]').forEach(function (el) {
if (el._brn) return;
el._brn = setup(el);
if (reduced()) {
el._brn.hot.style.display = 'none';
return;
}
mask(el._brn.base, 'linear-gradient(transparent,transparent)');
mask(el._brn.hot, 'linear-gradient(transparent,transparent)');
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-burn-in-text>Forged in fire</h1>
<!-- or, with a blue flame and no sparks: -->
<h1 data-burn-in-text data-color="#38bdf8" data-sparks="false">Forged in fire</h1>