Particle Life
Hundreds of coloured particles follow a secret table of likes and dislikes. Everything pushes away whatever gets too close, but beyond that each colour is drawn to some colours and pushed away by others. Out of those simple rules come cells, chasers and spinning clusters that look alive. Tap for a new set of rules.
Tap for new rules
How to use it
- Paste the snippet just before
</head>. - Add
data-particle-lifeto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-count | Number of particles. Default about one per 450 px², 120 to 900. |
data-types | Number of colours, 2 to 8. Default 5. |
data-colors | Comma-separated colours. Default pink, violet, sky, yellow, green, orange, red and white. |
data-shuffle | Seconds between new random rules. Default 0 (keep the same rules). |
data-shuffle-on-tap="false" | Taps and clicks don’t change the rules. |
The edges wrap round, so a cluster leaving one side comes back on the other. It runs on a transparent canvas, paused when off screen. With reduced motion it shows a still frame once the clusters have formed.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Particle Life: a few hundred coloured particles, and a secret table of
// likes and dislikes between the colours. Every particle pushes away
// anything that gets too close, but beyond that it's drawn towards the
// colours it likes and away from the ones it doesn't. Out of those simple
// rules come cells, chasers and spinning clusters that look alive.
// Tap (or click) for a new set of rules. The edges wrap round.
// Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-particle-life style="height:360px;background:#0b0815"></div>
// Optional: data-count (default about one per 450 px², 120 to 900),
// data-types (number of colours, 2 to 8, default 5), data-colors
// (comma-separated colours), data-shuffle (seconds between new rules;
// default 0, never), data-shuffle-on-tap="false" (taps do nothing).
var DEFAULTS = { types: 5, colors: ['#f472b6', '#a855f7', '#38bdf8', '#facc15', '#4ade80', '#fb923c', '#f87171', '#e2e8f0'] };
var BETA = 0.3, FRICTION = 0.04;
var css =
'[data-particle-life]{position:relative;isolation:isolate;overflow:hidden}' +
'.ptl-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 canvas = document.createElement('canvas');
canvas.className = 'ptl-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
var types = Math.max(2, Math.min(8, +d.types || (d.colors ? colors.length : DEFAULTS.types)));
var every = (+d.shuffle || 0) * 1000;
var w = 0, h = 0, dpr = 1, rmax = 60, clock = 0;
var px, py, vx, vy, kind, matrix = [], gcols = 0, grows = 0, head, next;
function shuffle() {
matrix = [];
for (var a = 0; a < types; a++) {
matrix.push([]);
for (var b = 0; b < types; b++) matrix[a].push(Math.random() * 2 - 1);
}
clock = 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);
rmax = Math.max(36, Math.min(80, Math.min(w, h) * 0.2));
var n = +d.count || Math.round(Math.min(900, Math.max(120, (w * h) / 450)));
px = new Float32Array(n); py = new Float32Array(n);
vx = new Float32Array(n); vy = new Float32Array(n);
kind = new Uint8Array(n); next = new Int32Array(n);
for (var i = 0; i < n; i++) {
px[i] = Math.random() * w; py[i] = Math.random() * h;
kind[i] = i % types;
}
gcols = Math.max(1, Math.floor(w / rmax));
grows = Math.max(1, Math.floor(h / rmax));
head = new Int32Array(gcols * grows);
}
// Pushed apart up close; beyond that, pulled or pushed by how much a likes b.
function force(r, a) {
if (r < BETA) return r / BETA - 1;
if (r < 1) return a * (1 - Math.abs(2 * r - 1 - BETA) / (1 - BETA));
return 0;
}
// The bucket and its neighbours either side, wrapping, without repeats.
function around(g, count) {
var out = [g];
if (count > 1) out.push((g + 1) % count);
if (count > 2) out.push((g + count - 1) % count);
return out;
}
function step(dt) {
var n = px.length, s = dt / 1000;
var damp = Math.pow(0.5, s / FRICTION);
var cw = w / gcols, ch = h / grows;
// Bucket the particles, so each only checks its neighbours.
head.fill(-1);
for (var i = 0; i < n; i++) {
var k = Math.min(grows - 1, Math.floor(py[i] / ch)) * gcols + Math.min(gcols - 1, Math.floor(px[i] / cw));
next[i] = head[k]; head[k] = i;
}
for (i = 0; i < n; i++) {
var fx = 0, fy = 0, gx = Math.min(gcols - 1, Math.floor(px[i] / cw)), gy = Math.min(grows - 1, Math.floor(py[i] / ch));
var row = matrix[kind[i]];
var xs = around(gx, gcols), ys = around(gy, grows);
for (var oy = 0; oy < ys.length; oy++) {
for (var ox = 0; ox < xs.length; ox++) {
var j = head[ys[oy] * gcols + xs[ox]];
for (; j !== -1; j = next[j]) {
if (j === i) continue;
var dx = px[j] - px[i], dy = py[j] - py[i];
// The shortest way round, on a board whose edges wrap.
if (dx > w / 2) dx -= w; else if (dx < -w / 2) dx += w;
if (dy > h / 2) dy -= h; else if (dy < -h / 2) dy += h;
var r = Math.sqrt(dx * dx + dy * dy);
if (r > 0 && r < rmax) {
var f = force(r / rmax, row[kind[j]]) / r;
fx += dx * f; fy += dy * f;
}
}
}
}
vx[i] = vx[i] * damp + fx * rmax * 8 * s;
vy[i] = vy[i] * damp + fy * rmax * 8 * s;
}
for (i = 0; i < n; i++) {
px[i] += vx[i] * s; py[i] += vy[i] * s;
if (px[i] < 0) px[i] += w; else if (px[i] >= w) px[i] -= w;
if (py[i] < 0) py[i] += h; else if (py[i] >= h) py[i] -= h;
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var r = Math.max(1.6, Math.min(2.6, Math.min(w, h) / 140));
for (var t = 0; t < types; t++) {
ctx.fillStyle = colors[t % colors.length];
// A faint halo, then the dot.
ctx.globalAlpha = 0.18;
ctx.beginPath();
for (var i = t; i < px.length; i += types) { ctx.moveTo(px[i] + r * 2.4, py[i]); ctx.arc(px[i], py[i], r * 2.4, 0, Math.PI * 2); }
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
for (i = t; i < px.length; i += types) { ctx.moveTo(px[i] + r, py[i]); ctx.arc(px[i], py[i], r, 0, Math.PI * 2); }
ctx.fill();
}
}
resize();
shuffle();
if (reduced()) {
// A still frame, once the clusters have formed.
for (var k = 0; k < 240; k++) step(16);
draw();
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (d.shuffleOnTap !== 'false') el.addEventListener('pointerdown', shuffle);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(33, now - last) : 16;
last = now;
clock += dt;
if (every && clock > every) shuffle();
step(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-particle-life]').forEach(function (el) {
if (el._ptl) return;
el._ptl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-particle-life style="height: 360px; background: #0b0815"></div>
<!-- new rules every 15 seconds: -->
<div data-particle-life data-shuffle="15" style="height: 360px; background: #0b0815"></div>