← Generative art

Random Walkers

A handful of walkers wander the box, each step a toss of up, down, left or right, and each leaves a trail in its own slowly shifting colour. Old trails fade as new ones are laid, so the picture never fills up. Tap or click to let a few more walkers loose from that spot.

Tap to add walkers

How to use it

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

Options

Add any of these attributes to the same element.

data-walkersHow many walkers. Default scaled to the box’s area, 6 to 24.
data-stepStep length in px. Default 6.
data-speedSteps per second. Default 30.
data-modegrid (right-angle steps) or free (steps in any direction). Default grid.
data-colorsComma-separated colours, one per walker in turn. Default a rainbow that drifts over time.
data-fadeHow fast old trails fade, from 0 (never) to 1. Default 0.5.
data-widthTrail width in px. Default 2.

Walkers never step straight back the way they came, which makes the trails a little less tangled, and they bounce off the walls. It’s paused when off screen, and with reduced motion it shows the trails of a few hundred steps.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Random Walkers: a handful of walkers wander the box, each step a coin
    // toss of up, down, left or right, and each leaves a trail in its own
    // slowly shifting colour. Old trails fade away as new ones are laid.
    // Tap or click to release a few more walkers from that spot.
    // Usage: <div data-random-walkers style="height:360px;background:#0f0a1e"></div>
    // Optional: data-walkers (how many, default scaled to the box's area),
    // data-step (step length in px, default 6), data-speed (steps per second,
    // default 30), data-mode ("grid" for right-angle steps or "free" for
    // steps in any direction, default grid), data-colors (comma-separated
    // colours; default a rainbow of hues), data-fade (how fast old trails
    // fade, 0 never to 1, default 0.5), data-width (trail width in px,
    // default 2).
    var DEFAULTS = { step: 6, speed: 30, mode: 'grid', fade: 0.5, width: 2 };

    var css =
      '[data-random-walkers]{position:relative;isolation:isolate;overflow:hidden}' +
      '.rwk-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; }

    function build(el) {
      var d = el.dataset;
      var trails = document.createElement('canvas');
      var heads = document.createElement('canvas');
      trails.className = heads.className = 'rwk-canvas';
      trails.setAttribute('aria-hidden', 'true');
      heads.setAttribute('aria-hidden', 'true');
      el.insertBefore(heads, el.firstChild);
      el.insertBefore(trails, heads);
      var tctx = trails.getContext('2d'), hctx = heads.getContext('2d');
      var step = +d.step || DEFAULTS.step;
      var speed = +d.speed || DEFAULTS.speed;
      var free = (d.mode || DEFAULTS.mode) === 'free';
      var fade = d.fade !== undefined ? Math.max(0, Math.min(1, +d.fade)) : DEFAULTS.fade;
      var lw = +d.width || DEFAULTS.width;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : null;
      var w = 0, h = 0, dpr = 1, walkers = [], acc = 0, fadeAcc = 0, hueT = 0;
      var DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];

      function walker(x, y, i) {
        var o = { x: x, y: y, hue: Math.random() * 360, dir: Math.floor(Math.random() * 4), ang: Math.random() * Math.PI * 2 };
        if (colors) o.color = colors[i % colors.length];
        return o;
      }

      function snap(v) { return free ? v : Math.round(v / step) * step; }

      function populate() {
        walkers = [];
        var n = +d.walkers || Math.round(Math.max(6, Math.min(24, w * h / 9000)));
        for (var i = 0; i < n; i++) walkers.push(walker(snap(w / 2 + (Math.random() - 0.5) * w * 0.6), snap(h / 2 + (Math.random() - 0.5) * h * 0.6), i));
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        [trails, heads].forEach(function (c) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); });
        tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        hctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        tctx.lineCap = 'round';
        populate();
        return true;
      }

      function colorOf(wk) {
        return wk.color || 'hsl(' + Math.round((wk.hue + hueT) % 360) + ',85%,66%)';
      }

      function walk() {
        tctx.lineWidth = lw;
        for (var i = 0; i < walkers.length; i++) {
          var wk = walkers[i], nx, ny;
          if (free) {
            wk.ang += (Math.random() - 0.5) * 1.6;
            nx = wk.x + Math.cos(wk.ang) * step;
            ny = wk.y + Math.sin(wk.ang) * step;
          } else {
            // Mostly a fresh coin toss, never straight back the way it came.
            var dir = Math.floor(Math.random() * 4);
            if ((dir ^ 1) === wk.dir) dir = wk.dir;
            wk.dir = dir;
            nx = wk.x + DIRS[dir][0] * step;
            ny = wk.y + DIRS[dir][1] * step;
          }
          // Walls bounce walkers back in.
          if (nx < 2 || nx > w - 2) { nx = wk.x - (nx - wk.x); wk.ang = Math.PI - wk.ang; wk.dir ^= wk.dir < 2 ? 1 : 0; }
          if (ny < 2 || ny > h - 2) { ny = wk.y - (ny - wk.y); wk.ang = -wk.ang; wk.dir ^= wk.dir >= 2 ? 1 : 0; }
          tctx.strokeStyle = colorOf(wk);
          tctx.beginPath();
          tctx.moveTo(wk.x, wk.y);
          tctx.lineTo(nx, ny);
          tctx.stroke();
          wk.x = nx; wk.y = ny;
        }
      }

      function drawHeads() {
        hctx.clearRect(0, 0, w, h);
        for (var i = 0; i < walkers.length; i++) {
          var wk = walkers[i], c = colorOf(wk);
          hctx.globalAlpha = 0.35;
          hctx.fillStyle = c;
          hctx.beginPath();
          hctx.arc(wk.x, wk.y, lw * 3.2, 0, Math.PI * 2);
          hctx.fill();
          hctx.globalAlpha = 1;
          hctx.fillStyle = '#fff';
          hctx.beginPath();
          hctx.arc(wk.x, wk.y, lw * 1.2, 0, Math.PI * 2);
          hctx.fill();
        }
      }

      // Old trails fade by wiping a little of them away.
      function dim(amount) {
        tctx.globalCompositeOperation = 'destination-out';
        tctx.fillStyle = 'rgba(0,0,0,' + amount + ')';
        tctx.fillRect(0, 0, w, h);
        tctx.globalCompositeOperation = 'source-over';
      }

      resize();
      if (reduced()) {
        for (var q = 0; q < 300; q++) { walk(); hueT += 0.4; }
        drawHeads();
        if (window.ResizeObserver) new ResizeObserver(function () {
          if (resize()) { for (var q2 = 0; q2 < 300; q2++) { walk(); hueT += 0.4; } drawHeads(); }
        }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) drawHeads(); }).observe(el);

      el.addEventListener('pointerdown', function (e) {
        var b = el.getBoundingClientRect(), x = snap(e.clientX - b.left), y = snap(e.clientY - b.top);
        for (var i = 0; i < 3; i++) walkers.push(walker(x, y, walkers.length));
        // Keep the crowd from growing without end.
        while (walkers.length > 60) walkers.shift();
      });

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(100, now - last) : 16;
          last = now;
          hueT += dt / 1000 * 12;
          acc += dt / 1000 * speed;
          var n = 0;
          while (acc >= 1 && n < 20) { acc -= 1; walk(); n++; }
          if (fade > 0) {
            // Fade in coarse steps: tiny alpha values round down to nothing.
            fadeAcc += dt / 1000 * fade;
            if (fadeAcc >= 0.12) { dim(0.12); fadeAcc -= 0.12; }
          }
          drawHeads();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-random-walkers style="height: 360px; background: #0f0a1e"></div>

<!-- any direction, in brand colours: -->
<div data-random-walkers data-mode="free" data-colors="#a855f7,#ec4899"
     style="height: 360px; background: #0f0a1e"></div>