← Particles

Smoke Puffs

Soft, lumpy puffs of smoke billow up from the bottom of a section, slowing as they rise, swelling and spreading out, curling on the breeze and thinning away to nothing. Tap to send a puff up from that spot.

Coming soon

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-smoke-puffs to a section, hero or card. Light smoke reads best on a dark background; set data-color for dark smoke on a light one.

Options

Add any of these attributes to the same element.

data-fromWhere puffs start: center (default), left, right or spread for random spots along the bottom.
data-rateTime between puffs. Default 650 ms.
data-sizeStarting size of a puff, in px. It grows about four times as it rises. Default 30.
data-colorSmoke colour. Default #d6d3d1.
data-windSideways drift, from -1 (left) to 1 (right). Default 0.15.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the smoke hangs still. From code, animacious.smokePuff(element, x, y) sends one up.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Smoke Puffs: soft, lumpy puffs of smoke billow up from the bottom of a
    // section, slowing as they rise, swelling and spreading out, curling a
    // little and thinning away to nothing. Tap or click to send a puff up from
    // that spot. Drawn on a transparent canvas behind your content, and paused
    // when off screen.
    // Usage: <section data-smoke-puffs>…</section>
    // Optional: data-from ("center", default, "left", "right" or "spread":
    // from random spots along the bottom), data-rate (ms between puffs,
    // default 650), data-size (starting size of a puff in px, default 30),
    // data-color (smoke colour, default #d6d3d1), data-wind (sideways drift,
    // -1 to 1, default 0.15).
    // Script use: animacious.smokePuff(element, x, y) sends one from code.
    var DEFAULTS = { rate: 650, size: 30, color: '#d6d3d1', wind: 0.15 };

    var css =
      '[data-smoke-puffs]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
      '.smp-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);

    // A soft, lumpy cloud on a 128 × 128 box, built from overlapping blurry blobs.
    function sprite(color) {
      var c = document.createElement('canvas');
      c.width = c.height = 128;
      var g = c.getContext('2d');
      var rgb = toRgb(color);
      for (var i = 0; i < 22; i++) {
        // Lumps cluster towards the middle and get smaller towards the edge.
        var a = Math.random() * Math.PI * 2, r = Math.pow(Math.random(), 0.7) * 34;
        var x = 64 + Math.cos(a) * r, y = 64 + Math.sin(a) * r;
        var rad = 30 - r * 0.45 + Math.random() * 10;
        var grad = g.createRadialGradient(x, y, 0, x, y, rad);
        grad.addColorStop(0, 'rgba(' + rgb + ',.2)');
        grad.addColorStop(0.5, 'rgba(' + rgb + ',.09)');
        grad.addColorStop(1, 'rgba(' + rgb + ',0)');
        g.fillStyle = grad;
        g.fillRect(0, 0, 128, 128);
      }
      return c;
    }

    function toRgb(color) {
      var probe = document.createElement('canvas').getContext('2d');
      probe.fillStyle = color;
      var hex = probe.fillStyle;               // normalised to #rrggbb
      if (hex.charAt(0) !== '#') return '214,211,209';
      return parseInt(hex.substr(1, 2), 16) + ',' + parseInt(hex.substr(3, 2), 16) + ',' + parseInt(hex.substr(5, 2), 16);
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'smp-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var from = d.from || 'center';
      var rate = +d.rate || DEFAULTS.rate;
      var size = +d.size || DEFAULTS.size;
      var wind = d.wind !== undefined ? +d.wind : DEFAULTS.wind;
      var color = d.color || DEFAULTS.color;
      var imgs = [sprite(color), sprite(color), sprite(color), sprite(color)];
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0, next = 0;
      var puffs = [];

      function originX() {
        if (from === 'left') return w * 0.18;
        if (from === 'right') return w * 0.82;
        if (from === 'spread') return R(0.08, 0.92, 3) * w;
        return w * 0.5;
      }

      function puff(x, y, boost) {
        var blobs = [];
        var n = Math.floor(R(5, 9));
        for (var i = 0; i < n; i++) {
          var a = Math.random() * Math.PI * 2;
          blobs.push({
            dx: Math.cos(a) * R(0.1, 0.55, 2), dy: Math.sin(a) * R(0.1, 0.45, 2),
            r: R(0.7, 1.2, 2), img: imgs[Math.floor(Math.random() * imgs.length)],
            rot: Math.random() * Math.PI * 2, spin: R(-0.0004, 0.0004, 5)
          });
        }
        puffs.push({
          x: x, y: y, x0: x,
          v: R(0.07, 0.11, 3) * (boost || 1),    // px per ms, slowing as it rises
          s: size * R(0.85, 1.2, 2), age: 0, life: R(4200, 6000),
          curl: R(-1, 1, 2), ph: Math.random() * Math.PI * 2, blobs: blobs
        });
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        var ow = w, oh = h;
        w = el.clientWidth;
        h = el.clientHeight;
        // Keep the puffs in the same places, relative to the new size.
        if (ow && oh && w && h) puffs.forEach(function (p) { p.x *= w / ow; p.y *= h / oh; });
        canvas.width = w * dpr;
        canvas.height = h * dpr;
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        for (var i = 0; i < puffs.length; i++) {
          var p = puffs[i];
          var k = p.age / p.life;
          // Swells fast at first, then keeps spreading slowly.
          var grow = 1 + 3.2 * (1 - Math.pow(1 - k, 2.2));
          var fade = Math.min(1, p.age / 700) * Math.pow(1 - k, 1.4);
          for (var j = 0; j < p.blobs.length; j++) {
            var b = p.blobs[j];
            var r = p.s * b.r * grow * 1.25;
            var spreadOut = p.s * grow * (1 + k * 0.6);
            ctx.globalAlpha = fade * 0.6;
            ctx.save();
            ctx.translate(p.x + b.dx * spreadOut, p.y + b.dy * spreadOut);
            ctx.rotate(b.rot + b.spin * p.age);
            ctx.drawImage(b.img, -r, -r, r * 2, r * 2);
            ctx.restore();
          }
        }
        ctx.globalAlpha = 1;
      }

      function advance(dt) {
        t += dt;
        if (t >= next) {
          puff(originX() + R(-6, 6), h + size * 0.4);
          // Now and then a quick second puff, like a little chug.
          if (Math.random() < 0.25) puff(originX() + R(-6, 6), h + size * 0.4, 0.85);
          next = t + rate * R(0.7, 1.3, 2);
        }
        for (var i = puffs.length - 1; i >= 0; i--) {
          var p = puffs[i];
          p.age += dt;
          if (p.age >= p.life) { puffs.splice(i, 1); continue; }
          var k = p.age / p.life;
          p.y -= p.v * dt * (1 - k * 0.75);
          // Wind pushes harder the higher it gets, plus a lazy curl.
          p.x += (wind * 0.03 * (0.4 + k) + Math.sin(p.age * 0.0012 + p.ph) * 0.012 * p.curl) * dt;
        }
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);

      // Start mid-way, so it's never empty.
      for (var warm = 0; warm < 220; warm++) advance(16);

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

      el.addEventListener('pointerdown', function (e) {
        var r = el.getBoundingClientRect();
        puff(e.clientX - r.left, e.clientY - r.top, 1.2);
      });
      el._smpPuff = function (x, y) { puff(x, y, 1.2); };

      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;
          advance(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

    window.animacious = window.animacious || {};
    window.animacious.smokePuff = function (el, x, y) {
      if (el && el._smpPuff) el._smpPuff(x === undefined ? el.clientWidth / 2 : x, y === undefined ? el.clientHeight : y);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-smoke-puffs style="background: #1c1917; color: #fff">
  <h1>Coming soon</h1>
</section>