← Illustrations

Campfire

A little night scene: flames lick and flicker over crossed logs, a warm glow pulses on the ground and sparks drift up into the dark under a sky full of stars. Cosy.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-campfire> where the scene should go. It fills the element with an SVG scene.

Options

Add any of these attributes to the same element.

data-sparksNumber of sparks in the air. Default 18.
data-speedFlicker speed multiplier. Default 1.
data-stars="false"No stars in the sky.

It is 8:5 with a night-sky background by default; set your own height or background on the element to change that. It pauses when off screen. With reduced motion it shows one still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Campfire: a little night scene. Flames lick and flicker over crossed
    // logs, a warm glow pulses on the ground and sparks drift up into the
    // dark. Drawn as SVG that fills the element, and paused when off screen.
    // Usage: <div data-campfire></div>
    // It is 8:5 by default with a night-sky background; give it your own
    // height or background to change that.
    // Optional: data-sparks (number of sparks, default 18), data-speed
    // (flicker speed multiplier, default 1), data-stars="false" (no stars).
    var DEFAULTS = { sparks: 18, speed: 1 };
    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;

    var css =
      '[data-campfire]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
      'background:radial-gradient(ellipse 70% 60% at 50% 100%,#3b1d4a 0%,#1a1033 55%,#0b0a1f 100%)}' +
      '.cfr-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // A flame tongue standing on (0, 0): w is the half-width at the base,
    // h its height, lean how far the tip sways sideways.
    function flame(w, h, lean) {
      var tx = lean, ty = -h;
      return 'M' + (-w) + ' 0' +
        'C' + (-w * 1.1) + ' ' + (-h * 0.38) + ' ' + (-w * 0.35 + lean * 0.4) + ' ' + (-h * 0.62) + ' ' + tx + ' ' + ty +
        'C' + (w * 0.35 + lean * 0.4) + ' ' + (-h * 0.62) + ' ' + (w * 1.1) + ' ' + (-h * 0.38) + ' ' + w + ' 0' +
        'Q0 ' + (w * 0.55) + ' ' + (-w) + ' 0Z';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var count = d.sparks !== undefined ? Math.max(0, +d.sparks) : DEFAULTS.sparks;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A campfire burning at night');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var stars = '';
      if (d.stars !== 'false') {
        for (var i = 0; i < 70; i++) {
          var sx = (Math.random() * 1400 - 500).toFixed(1), sy = (Math.random() * 230 - 120).toFixed(1);
          stars += '<circle class="cfr-star" cx="' + sx + '" cy="' + sy + '" r="' + (Math.random() * 0.9 + 0.4).toFixed(2) + '" fill="#fff" opacity="' + (Math.random() * 0.5 + 0.3).toFixed(2) + '"/>';
        }
      }

      var g = 'cfr' + n;
      el.innerHTML =
        '<svg class="cfr-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<radialGradient id="' + g + '-aura"><stop offset="0" stop-color="#fb923c" stop-opacity=".45"/><stop offset=".5" stop-color="#f97316" stop-opacity=".12"/><stop offset="1" stop-color="#f97316" stop-opacity="0"/></radialGradient>' +
        '<radialGradient id="' + g + '-pool"><stop offset="0" stop-color="#fdba74" stop-opacity=".75"/><stop offset="1" stop-color="#ea580c" stop-opacity="0"/></radialGradient>' +
        '<linearGradient id="' + g + '-log" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#92400e"/><stop offset="1" stop-color="#451a03"/></linearGradient>' +
        '</defs>' +
        stars +
        '<circle cx="330" cy="44" r="24" fill="#fef3c7" opacity=".08"/><circle cx="330" cy="44" r="12" fill="#fef3c7" opacity=".9"/>' +
        // Ground: wide enough to fill any width.
        '<path d="M-800 214 Q200 196 1200 214 L1200 600 L-800 600Z" fill="#160d26"/>' +
        '<circle class="cfr-aura" cx="200" cy="170" r="150" fill="url(#' + g + '-aura)"/>' +
        '<ellipse class="cfr-pool" cx="200" cy="214" rx="110" ry="18" fill="url(#' + g + '-pool)"/>' +
        '<g transform="translate(200 206)">' +
          '<ellipse cx="0" cy="-2" rx="34" ry="7" fill="#f97316" opacity=".55"/>' +
          '<g class="cfr-flames"></g>' +
          '<g transform="rotate(-14)"><rect x="-56" y="-8" width="112" height="16" rx="8" fill="url(#' + g + '-log)"/><ellipse cx="54" cy="0" rx="5" ry="8" fill="#d6a46b"/><ellipse cx="54" cy="0" rx="2.5" ry="4.5" fill="#a16207"/></g>' +
          '<g transform="rotate(14)"><rect x="-56" y="-8" width="112" height="16" rx="8" fill="url(#' + g + '-log)"/><ellipse cx="-54" cy="0" rx="5" ry="8" fill="#d6a46b"/><ellipse cx="-54" cy="0" rx="2.5" ry="4.5" fill="#a16207"/></g>' +
          '<ellipse cx="-74" cy="10" rx="13" ry="8" fill="#3f3a52"/><ellipse cx="-48" cy="14" rx="12" ry="7" fill="#4c4663"/><ellipse cx="-18" cy="16" rx="12" ry="6.5" fill="#3f3a52"/>' +
          '<ellipse cx="16" cy="16" rx="12" ry="6.5" fill="#4c4663"/><ellipse cx="46" cy="14" rx="12" ry="7" fill="#3f3a52"/><ellipse cx="72" cy="10" rx="13" ry="8" fill="#4c4663"/>' +
        '</g>' +
        '<g class="cfr-sparks"></g>' +
        '</svg>';

      var svg = el.firstChild;
      var flamesG = svg.querySelector('.cfr-flames');
      var sparksG = svg.querySelector('.cfr-sparks');
      var aura = svg.querySelector('.cfr-aura');
      var pool = svg.querySelector('.cfr-pool');

      // Back to front: wide orange tongues, then yellow, then a pale core.
      var tongues = [
        { x: -22, w: 16, h: 58, col: '#ea580c', f: 1.3, p: 0.0 },
        { x: 22, w: 16, h: 62, col: '#ea580c', f: 1.1, p: 2.1 },
        { x: 0, w: 30, h: 100, col: '#f97316', f: 0.9, p: 1.0 },
        { x: -10, w: 20, h: 70, col: '#fb923c', f: 1.5, p: 3.3 },
        { x: 12, w: 18, h: 66, col: '#fb923c', f: 1.7, p: 4.4 },
        { x: 0, w: 20, h: 70, col: '#fbbf24', f: 1.2, p: 5.2 },
        { x: 2, w: 11, h: 40, col: '#fef3c7', f: 1.9, p: 0.7 }
      ];
      tongues.forEach(function (tg) {
        tg.el = document.createElementNS(NS, 'path');
        tg.el.setAttribute('fill', tg.col);
        tg.el.setAttribute('transform', 'translate(' + tg.x + ' 0)');
        flamesG.appendChild(tg.el);
      });

      var sparks = [];
      for (var k = 0; k < count; k++) {
        var c = document.createElementNS(NS, 'circle');
        c.setAttribute('fill', k % 3 ? '#fdba74' : '#fef08a');
        sparksG.appendChild(c);
        sparks.push({ el: c, life: 0, max: 1 });
      }

      function spawn(s, anywhere) {
        s.x = 200 + (Math.random() - 0.5) * 50;
        s.y = 190 - Math.random() * 30;
        s.vx = (Math.random() - 0.5) * 0.012;
        s.vy = -(0.03 + Math.random() * 0.04);
        s.r = 0.8 + Math.random() * 1.4;
        s.max = 1800 + Math.random() * 2200;
        s.life = anywhere ? Math.random() * s.max : 0;
        s.ph = Math.random() * 6.28;
        if (anywhere) { s.x += s.vx * s.life; s.y += s.vy * s.life; }
      }
      sparks.forEach(function (s) { spawn(s, true); });

      var t = 0;
      function draw(dt) {
        t += dt * speed;
        var s = t / 1000, total = 0;
        tongues.forEach(function (tg) {
          // A few sines at odd ratios: never quite repeating.
          var a = Math.sin(s * 6.1 * tg.f + tg.p), b = Math.sin(s * 9.7 * tg.f + tg.p * 1.7), c = Math.sin(s * 3.3 * tg.f + tg.p * 0.6);
          var h = tg.h * (1 + 0.12 * a + 0.07 * b);
          var lean = tg.h * (0.1 * c + 0.06 * b);
          total += h / tg.h;
          tg.el.setAttribute('d', flame(tg.w * (1 + 0.05 * c), h, lean));
        });
        var heat = total / tongues.length;
        aura.setAttribute('opacity', (0.75 + (heat - 1) * 2.4).toFixed(3));
        pool.setAttribute('opacity', (0.8 + (heat - 1) * 2.8).toFixed(3));

        for (var i = 0; i < sparks.length; i++) {
          var p = sparks[i];
          p.life += dt * speed;
          if (p.life > p.max) spawn(p, false);
          p.x += (p.vx + Math.sin(p.life / 260 + p.ph) * 0.02) * dt * speed;
          p.y += p.vy * dt * speed;
          var k = p.life / p.max;
          p.el.setAttribute('cx', p.x.toFixed(1));
          p.el.setAttribute('cy', p.y.toFixed(1));
          p.el.setAttribute('r', (p.r * (1 - k * 0.6)).toFixed(2));
          p.el.setAttribute('opacity', (k < 0.1 ? k / 0.1 : 1 - (k - 0.1) / 0.9).toFixed(2));
        }
      }

      if (reduced()) {
        // A still frame: flames mid-flicker, sparks frozen in the air.
        draw(16);
        return;
      }

      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) : 16;
          last = now;
          draw(dt);
        }
      });
      draw(16);
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);

      // Stars twinkle gently.
      var starEls = svg.querySelectorAll('.cfr-star');
      if (starEls.length) {
        var tw = anime.animate(starEls, {
          opacity: function () { return anime.utils.random(0.1, 0.9, 2); },
          duration: function () { return anime.utils.random(1200, 3200); },
          delay: function () { return anime.utils.random(0, 2000); },
          ease: 'inOutSine',
          loop: true,
          alternate: true,
          autoplay: false
        });
        new IntersectionObserver(function (entries) {
          if (entries[0].isIntersecting) tw.play(); else tw.pause();
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-campfire]').forEach(function (el) {
        if (el._cfr) return;   // idempotent
        el._cfr = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-campfire></div>

<!-- full width, shorter, no stars: -->
<div data-campfire data-stars="false" style="height: 280px"></div>