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
- Paste the snippet just before
</head>. - Add
data-random-walkersto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-walkers | How many walkers. Default scaled to the box’s area, 6 to 24. |
data-step | Step length in px. Default 6. |
data-speed | Steps per second. Default 30. |
data-mode | grid (right-angle steps) or free (steps in any direction). Default grid. |
data-colors | Comma-separated colours, one per walker in turn. Default a rainbow that drifts over time. |
data-fade | How fast old trails fade, from 0 (never) to 1. Default 0.5. |
data-width | Trail 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.
<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><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>