← Particles

Embers

Glowing embers drift up from the bottom of a section, wavering on the hot air and flickering as they cool from yellow to orange to a dull red before they fade.

Into the night

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-embers to a section with a dark background.

Options

Add any of these attributes to the same element.

data-densityEmbers per 100 × 100 px. Default 0.9.
data-speedSpeed multiplier. Default 1.
data-windSideways drift, from -1 (left) to 1 (right). Default 0.
data-colorsHot, warm and cool colours, comma-separated. Default #fff3b0,#ff8a1f,#b91c1c.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the embers hang still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Embers: glowing embers drift up from the bottom of a section, wavering on
    // the hot air and flickering as they cool from yellow to orange to a dull
    // red, then fade out. Drawn on a transparent canvas behind your content,
    // and paused when off screen.
    // Usage: <section data-embers style="background:#120806">…</section>
    // Optional: data-density (embers per 100 × 100 px, default 0.9),
    // data-speed (default 1), data-wind (sideways drift, -1 to 1, default 0),
    // data-colors (hot, warm and cool colours, default
    // "#fff3b0,#ff8a1f,#b91c1c").
    var DEFAULTS = { density: 0.9, speed: 1, wind: 0, colors: ['#fff3b0', '#ff8a1f', '#b91c1c'] };

    var css =
      '[data-embers]{position:relative;isolation:isolate;overflow:hidden}' +
      '.emb-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function rgb(hex) {
      var s = hex.trim().replace('#', '');
      if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
      var n = parseInt(s, 16);
      return isNaN(n) ? [255, 140, 40] : [n >> 16, (n >> 8) & 255, n & 255];
    }

    // Glow sprites along the cooling ramp: index 0 is hottest.
    var STEPS = 24;
    function sprites(colors) {
      var c = colors.map(rgb), out = [];
      for (var i = 0; i < STEPS; i++) {
        var k = i / (STEPS - 1), a, b, f;
        if (k < 0.4) { a = c[0]; b = c[1]; f = k / 0.4; } else { a = c[1]; b = c[2]; f = (k - 0.4) / 0.6; }
        var col = [0, 1, 2].map(function (j) { return Math.round(a[j] + (b[j] - a[j]) * f); }).join(',');
        var S = 48, cv = document.createElement('canvas');
        cv.width = cv.height = S;
        var g = cv.getContext('2d');
        var grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
        grad.addColorStop(0, 'rgba(255,250,235,' + (1 - k * 0.6) + ')');
        grad.addColorStop(0.1, 'rgba(' + col + ',1)');
        grad.addColorStop(0.3, 'rgba(' + col + ',.35)');
        grad.addColorStop(1, 'rgba(' + col + ',0)');
        g.fillStyle = grad;
        g.fillRect(0, 0, S, S);
        out.push(cv);
      }
      return out;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'emb-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var density = +d.density || DEFAULTS.density;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var wind = d.wind !== undefined ? +d.wind : DEFAULTS.wind;
      var img = sprites(d.colors ? d.colors.split(',') : DEFAULTS.colors);
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0, unit = 1;
      var embers = [];

      function ember(e, fresh) {
        e.x = Math.random() * w;
        e.y = h + R(0, 20);
        e.vy = R(45, 110) * unit;               // px/s upwards
        e.size = R(1.4, 3.6, 2);               // core radius
        e.life = R(3.2, 6.5, 2);
        e.age = fresh ? 0 : Math.random() * e.life;
        if (!fresh) e.y = h - e.vy * e.age;    // already on its way
        e.freq = R(0.8, 2.2, 2);
        e.phase = Math.random() * 6.28;
        e.wobble = R(10, 34) * unit;
        e.flicker = R(9, 18);
        e.ox = e.x;
        return e;
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        unit = Math.max(0.7, h / 400);
        var n = Math.round(Math.min(260, Math.max(18, (w * h * density) / 10000)));
        while (embers.length < n) embers.push(ember({}, false));
        embers.length = n;
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        for (var i = 0; i < embers.length; i++) {
          var e = embers[i], k = e.age / e.life;
          // Fade in quickly, cool, then fade out over the last third.
          var alpha = Math.min(1, k * 8) * (k > 0.65 ? 1 - (k - 0.65) / 0.35 : 1);
          alpha *= 0.75 + 0.25 * Math.sin(t * e.flicker + e.phase);
          if (alpha <= 0) continue;
          var s = e.size * (6.5 - k * 3);
          ctx.globalAlpha = alpha;
          ctx.drawImage(img[Math.min(STEPS - 1, Math.floor(k * STEPS))], e.x - s, e.y - s, s * 2, s * 2);
        }
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 1;
      }

      function advance(dt) {
        t += dt;
        for (var i = 0; i < embers.length; i++) {
          var e = embers[i];
          e.age += dt;
          if (e.age > e.life || e.y < -20) { ember(e, true); continue; }
          // Rising air slows a little as it cools; the sideways waver grows.
          e.y -= e.vy * (1 - e.age / e.life * 0.4) * dt * speed;
          e.ox += wind * 40 * unit * dt * speed;
          e.x = e.ox + Math.sin(t * e.freq + e.phase) * e.wobble * (0.3 + e.age / e.life)
            + Math.sin(t * 0.37 + e.y * 0.01) * 8 * unit;
          if (e.ox < -40) e.ox += w + 80; else if (e.ox > w + 40) e.ox -= w + 80;
        }
      }

      resize();
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        advance(0.01);
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) / 1000 : 1 / 60;
          last = now;
          advance(dt);
          draw();
        }
      });
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-embers]').forEach(function (el) {
        if (el._emb) return;
        el._emb = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-embers style="background: #140806; color: #fff">
  <h1>Into the night</h1>
</section>