← Text effects

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

  1. Paste the snippet just before </head>.
  2. Add data-fire-text to 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-heightHow tall the flames reach above the letters. Default 1 em.
data-intensityHow many flames, from 0.2 to 3. Default 1.
data-sparksSparks per second. Default 6; 0 for none.
data-keep-colorKeep 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.

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