← Text reveals

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

  1. Paste the snippet just before </head>.
  2. Add data-burn-in-text to a heading or short line of text, ideally on a dark background.

Options

Add any of these attributes to the same element.

data-colorThe ember glow. Default #ff7a18.
data-durationTime for the edge to burn across. Default 1800 ms.
data-sparksfalse for no sparks.
data-loopBurn in, hold, fade and burn in again.
data-holdHow long the text stays before fading, when looping. Default 1800 ms.
data-delayWait 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.

1 · Snippet
<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>
2 · Your HTML
<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>