← Particles

Dust Motes

A soft shaft of light slants across a dim room, and specks of dust drift slowly through the air, glinting as they pass through the beam. Move your pointer or finger to stir the air.

Afternoon light

How to use it

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

Options

Add any of these attributes to the same element.

data-densityMotes per 100 × 100 px. Default 4.
data-angleThe beam’s tilt from straight down, in degrees. Default 28.
data-xWhere the beam comes in along the top, as a share of the width. Default 0.3.
data-widthHow wide the beam is, as a share of the box. Default 0.3.
data-colorColour of the light. Default #ffe2a8.
data-beam="false"Just the dust, no beam, for a light of your own.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the dust hangs still in the light.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dust Motes: a soft beam of light slants across a dim section, and specks
    // of dust drift slowly through the air, catching the light and glinting as
    // they pass through the beam. Moving the pointer (or a finger) stirs the
    // air. Drawn on a transparent canvas behind your content, and paused when
    // off screen.
    // Usage: <section data-dust-motes style="background:#17120e">…</section>
    // Optional: data-density (motes per 100 × 100 px, default 4),
    // data-angle (the beam's tilt from straight down, in degrees, default 28),
    // data-x (where the beam enters along the top, as a share of the width,
    // default 0.3), data-width (beam width as a share of the box, default
    // 0.3), data-color (light colour, default warm #ffe2a8),
    // data-beam="false" (motes only, for your own light).
    var DEFAULTS = { density: 4, angle: 28, x: 0.3, width: 0.3, color: '#ffe2a8' };

    var css =
      '[data-dust-motes]{position:relative;isolation:isolate;overflow:hidden}' +
      '.dmt-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,226,168' : (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'dmt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var density = +d.density || DEFAULTS.density;
      var tilt = (d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
      var bx = d.x !== undefined ? +d.x : DEFAULTS.x;
      var bw = +d.width || DEFAULTS.width;
      var col = rgb(d.color || DEFAULTS.color);
      var showBeam = d.beam !== 'false';
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0;
      var motes = [], beam = null, pointer = null;
      // The beam's axis: a point on the top edge and a direction down the beam.
      var ax = 0, dirX = Math.sin(tilt), dirY = Math.cos(tilt), half0 = 0, half1 = 0, length = 1;

      // A soft speck, pre-drawn.
      var dot = document.createElement('canvas');
      dot.width = dot.height = 16;
      var dg = dot.getContext('2d'), grad = dg.createRadialGradient(8, 8, 0, 8, 8, 8);
      grad.addColorStop(0, 'rgba(255,255,255,1)');
      grad.addColorStop(0.35, 'rgba(' + col + ',.8)');
      grad.addColorStop(1, 'rgba(' + col + ',0)');
      dg.fillStyle = grad;
      dg.fillRect(0, 0, 16, 16);

      // How lit a point is: 1 on the beam's centre line near the top, fading
      // towards its edges and further down.
      function light(x, y) {
        if (!showBeam) return 0.6;
        var rx = x - ax, ry = y;
        var along = rx * dirX + ry * dirY;
        var across = Math.abs(rx * dirY - ry * dirX);
        var k = Math.max(0, Math.min(1, along / length));
        var half = half0 + (half1 - half0) * k;
        var edge = 1 - across / half;
        if (edge <= 0) return 0;
        edge = edge * edge * (3 - 2 * edge);   // smoothstep
        return edge * (1 - k * 0.55);
      }

      function mote(m) {
        m.x = Math.random() * w;
        m.y = Math.random() * h;
        m.vx = 0; m.vy = 0;
        m.z = Math.random();                    // 0 far, 1 near
        m.r = 0.6 + m.z * m.z * 1.9;
        m.glint = R(1.5, 4, 2);
        m.phase = Math.random() * 6.28;
        return m;
      }

      // The beam itself, worked out once per resize at a quarter of the
      // resolution (it's soft anyway) and stretched to fit, which keeps it smooth.
      function paintBeam() {
        var q = 4, bw2 = Math.ceil(w / q) + 1, bh2 = Math.ceil(h / q) + 1;
        beam = document.createElement('canvas');
        beam.width = bw2;
        beam.height = bh2;
        if (!showBeam) return;
        var b = beam.getContext('2d'), img = b.createImageData(bw2, bh2), px = img.data;
        var c = col.split(',');
        for (var y = 0; y < bh2; y++) {
          for (var x = 0; x < bw2; x++) {
            var l = light(x * q, y * q), i = (y * bw2 + x) * 4;
            px[i] = +c[0]; px[i + 1] = +c[1]; px[i + 2] = +c[2];
            px[i + 3] = Math.round(l * l * 0.42 * 255 + l * 0.12 * 255);
          }
        }
        b.putImageData(img, 0, 0);
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // Keep the beam sensible on tall narrow screens.
        var size = Math.min(w, h * 1.6);
        ax = w * bx;
        half0 = size * bw * 0.32;
        half1 = size * bw * 0.75;
        length = Math.sqrt(w * w + h * h);
        paintBeam();
        var n = Math.round(Math.min(420, Math.max(40, (w * h * density) / 10000)));
        while (motes.length < n) motes.push(mote({}));
        motes.length = n;
      }

      function draw() {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        // The beam breathes very slightly, as if a cloud were passing.
        ctx.globalAlpha = 0.85 + 0.15 * Math.sin(t * 0.25);
        ctx.imageSmoothingEnabled = true;
        ctx.drawImage(beam, 0, 0, beam.width * 4 * dpr, beam.height * 4 * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = 'lighter';
        for (var i = 0; i < motes.length; i++) {
          var m = motes[i], l = light(m.x, m.y);
          // Dust is barely visible in the shade and glints in the light.
          var a = 0.1 + l * 1.6 * (0.55 + 0.45 * Math.sin(t * m.glint + m.phase)) * (0.5 + m.z * 0.5);
          if (a < 0.03) continue;
          var s = m.r * (1.6 + l * 2);
          ctx.globalAlpha = Math.min(1, a);
          ctx.drawImage(dot, m.x - s, m.y - s, s * 2, s * 2);
        }
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 1;
      }

      function advance(dt) {
        t += dt;
        // A faint, slowly turning draught moves all the air a little.
        var airX = Math.sin(t * 0.13) * 4 + 2, airY = Math.cos(t * 0.17) * 2.5 + 0.8;
        for (var i = 0; i < motes.length; i++) {
          var m = motes[i];
          // Brownian wander: tiny random nudges, gently damped.
          m.vx += R(-1, 1, 3) * 14 * dt;
          m.vy += R(-1, 1, 3) * 14 * dt;
          if (pointer) {
            var dx = m.x - pointer.x, dy = m.y - pointer.y, d2 = dx * dx + dy * dy;
            if (d2 < 6400) {
              var push = (1 - d2 / 6400) * dt * 6;
              m.vx += pointer.vx * push;
              m.vy += pointer.vy * push;
            }
          }
          m.vx *= Math.exp(-1.2 * dt);
          m.vy *= Math.exp(-1.2 * dt);
          var depth = 0.4 + m.z * 0.6;
          m.x += (m.vx + airX) * depth * dt;
          m.y += (m.vy + airY) * depth * dt;
          if (m.x < -4) m.x += w + 8; else if (m.x > w + 4) m.x -= w + 8;
          if (m.y < -4) m.y += h + 8; else if (m.y > h + 4) m.y -= h + 8;
        }
        if (pointer) { pointer.vx *= Math.exp(-6 * dt); pointer.vy *= Math.exp(-6 * dt); }
      }

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

      // Stir the air: motes near the pointer are carried along with it.
      el.addEventListener('pointermove', function (e) {
        var r = el.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
        if (pointer) {
          pointer.vx = Math.max(-300, Math.min(300, (x - pointer.x) * 6));
          pointer.vy = Math.max(-300, Math.min(300, (y - pointer.y) * 6));
          pointer.x = x; pointer.y = y;
        } else pointer = { x: x, y: y, vx: 0, vy: 0 };
      });
      el.addEventListener('pointerleave', function () { pointer = null; });

      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-dust-motes]').forEach(function (el) {
        if (el._dmt) return;
        el._dmt = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-dust-motes style="background: #17120e; color: #fff">
  <h1>Afternoon light</h1>
</section>