← Generative art

Falling Sand

Grains of sand pour from the top and tumble down, sliding off each other’s shoulders until they settle into dunes. The colour drifts as it pours, so the dunes come out layered like sandstone. Hover over it, or press on a phone, to pour your own.

Hover or press to pour

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-falling-sand to a box with a height and a background.

Options

Add any of these attributes to the same element.

data-autoplaySpouts wander to and fro and pour by themselves, pausing while you pour.
data-loopOnce it fills up, the floor opens, the sand drains away and it starts again.
data-grainGrain size in px. Default 3.
data-colorsComma-separated colours for the layers. Default warm sand tones.
data-rateGrains per spout per frame. Default 4.

It’s paused when off screen. With reduced motion it shows a few dunes, already poured.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Falling Sand: grains pour from the top and tumble down, sliding off each
    // other's shoulders until they settle into dunes. The colour shifts slowly
    // as it pours, so the dunes are layered like sandstone. Hover (or press,
    // on a touch screen) to pour your own. Paused when off screen.
    // Usage: <div data-falling-sand data-autoplay style="height:360px"></div>
    // Optional: data-autoplay (two spouts wander to and fro and pour by
    // themselves, pausing while you pour), data-loop (once it fills up, the
    // floor opens, the sand drains away and it starts again), data-grain
    // (grain size in px, default 3), data-colors (comma-separated colours for
    // the layers, default warm sand tones), data-rate (grains per spout per
    // frame, default 4).
    var DEFAULTS = { grain: 3, rate: 4, colors: '#f6c177,#eb9f5e,#d9774a,#f2d49b,#c9a26b' };

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

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

    // Several shades of each colour, as little-endian RGBA pixels.
    function palette(list) {
      var out = [0];
      list.split(',').forEach(function (hex) {
        var n = parseInt(hex.trim().replace('#', ''), 16), r = n >> 16, g = n >> 8 & 255, b = n & 255;
        for (var i = 0; i < 6; i++) {
          var f = 0.86 + i * 0.05;
          out.push((255 << 24 | Math.min(255, b * f) << 16 | Math.min(255, g * f) << 8 | Math.min(255, r * f)) >>> 0);
        }
      });
      return out;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'fsd-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var size = +d.grain || DEFAULTS.grain;
      var rate = +d.rate || DEFAULTS.rate;
      var pal = palette(d.colors || DEFAULTS.colors);
      var bands = (pal.length - 1) / 6;
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var loop = d.loop !== undefined && d.loop !== 'false';
      var fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0;
      var grid, img, buf, off = document.createElement('canvas'), octx = off.getContext('2d');
      var t = 0, count = 0, draining = false, full = false, pointer = null, quietUntil = 0, flip = 0;

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        cols = Math.max(10, Math.ceil(w / size));
        rows = Math.max(10, Math.ceil(h / size));
        off.width = cols; off.height = rows;
        img = octx.createImageData(cols, rows);
        buf = new Uint32Array(img.data.buffer);
        grid = new Uint8Array(cols * rows);
        count = 0; draining = false; full = false;
      }

      // The colour band changes every few seconds, so the dunes come out striped.
      function grainColour() {
        var band = Math.floor(t / 2600) % bands;
        return 1 + band * 6 + Math.floor(Math.random() * 6);
      }

      function pour(x, n) {
        for (var k = 0; k < n; k++) {
          var gx = Math.round(x + (Math.random() - 0.5) * 3), gy = Math.floor(Math.random() * 2);
          if (gx < 0 || gx >= cols) continue;
          var i = gy * cols + gx;
          if (!grid[i]) { grid[i] = grainColour(); count++; }
        }
      }

      // One tick, from the bottom row up: fall straight down if there's room,
      // otherwise slide down to one side.
      function tick() {
        if (draining) {
          for (var x = 0; x < cols; x++) {
            var b = (rows - 1) * cols + x;
            if (grid[b]) { grid[b] = 0; count--; }
          }
        }
        flip ^= 1;
        for (var y = rows - 2; y >= 0; y--) {
          var row = y * cols, below = row + cols;
          for (var j = 0; j < cols; j++) {
            var xx = flip ? j : cols - 1 - j, i = row + xx, c = grid[i];
            if (!c) continue;
            if (!grid[below + xx]) { grid[below + xx] = c; grid[i] = 0; continue; }
            var dir = Math.random() < 0.5 ? -1 : 1, a = xx + dir, bb = xx - dir;
            if (a >= 0 && a < cols && !grid[below + a]) { grid[below + a] = c; grid[i] = 0; }
            else if (bb >= 0 && bb < cols && !grid[below + bb]) { grid[below + bb] = c; grid[i] = 0; }
          }
        }
      }

      function advance(dt) {
        t += dt;
        var now = performance.now();
        if (!draining && !full) {
          if (pointer) {
            pour(pointer.x, rate + 2);
            quietUntil = now + 2500;
          } else if (auto && now > quietUntil) {
            // Two spouts drift back and forth at different paces.
            pour(cols * (0.5 + 0.38 * Math.sin(t / 2300)), rate);
            if (cols > 80) pour(cols * (0.5 + 0.42 * Math.sin(t / 3100 + 2)), rate);
          }
          // Nearly full near the top? Stop pouring (and drain, when looping).
          var top = 0, line = Math.floor(rows * 0.12) * cols;
          for (var x = 0; x < cols; x++) if (grid[line + x]) top++;
          if (top > cols * 0.3) {
            full = true;
            // Hold the finished dunes for a moment, then open the floor.
            if (loop) setTimeout(function () { full = false; draining = true; }, 1500);
          }
        }
        tick();
        tick();
        if (draining && count <= 0) { draining = false; count = 0; t = 0; }
      }

      function draw() {
        for (var i = 0; i < grid.length; i++) buf[i] = pal[grid[i]];
        octx.putImageData(img, 0, 0);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.imageSmoothingEnabled = false;
        // Bottom-aligned, so the floor is the bottom edge.
        ctx.drawImage(off, (w - cols * size) / 2, h - rows * size, cols * size, rows * size);
      }

      resize();
      if (reduced()) {
        // A still frame: a few dunes already poured.
        for (var k = 0; k < 900; k++) {
          t += 16;
          pour(cols * (0.3 + 0.4 * Math.sin(k / 140)), rate);
          tick();
        }
        for (k = 0; k < rows * 2; k++) tick();
        draw();
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      function at(e) {
        var r = el.getBoundingClientRect();
        pointer = { x: (e.clientX - r.left - (w - cols * size) / 2) / size };
      }
      el.addEventListener('pointerdown', at);
      el.addEventListener('pointermove', function (e) { if (fine || pointer) at(e); });
      // A mouse keeps pouring while it hovers; a finger stops when lifted.
      el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; });
      el.addEventListener('pointerleave', function () { pointer = null; });
      el.addEventListener('pointercancel', 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) : 16;
          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-falling-sand]').forEach(function (el) {
        if (el._fsd) return;
        el._fsd = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-falling-sand data-autoplay data-loop
     style="height: 360px; background: #24124a"></div>