Reaction-diffusion
Two imaginary chemicals spread across the box and react where they meet. From a few scattered seeds, spots and stripes grow, split and settle, just like the markings on a leopard or a zebrafish.
How to use it
- Paste the snippet just before
</head>. - Add
data-reaction-diffusionto a box with a height and a light background.
Options
Add any of these attributes to the same element.
data-pattern | spots, stripes, maze or coral. Default spots. A comma-separated list takes turns with data-loop. |
data-loop | Once the pattern has grown, hold it, dissolve it and grow the next one. |
data-color | Colour of the markings. Default #2a1a12. |
data-scale | Size of one simulation cell in px. Bigger is blobbier and lighter on the processor. Default 3. |
data-speed | Simulation steps per frame. Default 12. |
It’s the Gray-Scott model, a cousin of the maths Alan Turing proposed for animal markings, run on a transparent canvas so your background shows through. It’s paused when off screen, and with reduced motion it shows the grown pattern.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Reaction-diffusion: two imaginary chemicals spread across the box and
// react where they meet (the Gray-Scott model). From a few scattered
// seeds, spots and stripes grow and split like the markings on an animal's
// coat. Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-reaction-diffusion style="height:360px;background:#f4dfb8"></div>
// Optional: data-pattern ("spots", "stripes", "maze" or "coral"; default
// spots; a comma-separated list takes turns with data-loop), data-loop
// (once the pattern has grown, hold it, dissolve, and grow the next one),
// data-color (markings colour, default #2a1a12), data-scale (size of a
// simulation cell in px, default 3), data-speed (simulation steps per
// frame, default 12).
var DEFAULTS = { pattern: 'spots', color: '#2a1a12', scale: 3, speed: 12, grow: 11000 };
// Feed and kill rates for each pattern.
var PATTERNS = {
spots: [0.0367, 0.0649],
stripes: [0.042, 0.0635],
maze: [0.029, 0.057],
coral: [0.0545, 0.062]
};
var css =
'[data-reaction-diffusion]{position:relative;isolation:isolate;overflow:hidden}' +
'.rxd-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 = 'rxd-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var names = (d.pattern || DEFAULTS.pattern).split(',').map(function (p) { return p.trim(); })
.filter(function (p) { return PATTERNS[p]; });
if (!names.length) names = [DEFAULTS.pattern];
var which = 0;
var n = parseInt((d.color || DEFAULTS.color).replace('#', ''), 16);
var cr = n >> 16, cg = n >> 8 & 255, cb = n & 255;
var scale = +d.scale || DEFAULTS.scale;
var speed = +d.speed || DEFAULTS.speed;
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, cell = scale;
var U, V, U2, V2, img, off = document.createElement('canvas'), octx = off.getContext('2d');
var F = 0, K = 0, clock = 0, waiting = false;
function seed() {
var p = PATTERNS[names[which % names.length]];
F = p[0]; K = p[1];
U.fill(1); V.fill(0);
// A scattering of small seeds, about one per 3,000 cells.
var count = Math.max(4, Math.round(cols * rows / 3000));
for (var s = 0; s < count; s++) {
var cx = Math.floor(Math.random() * cols), cy = Math.floor(Math.random() * rows), r = 2 + Math.floor(Math.random() * 2);
for (var y = -r; y <= r; y++) {
for (var x = -r; x <= r; x++) {
var i = ((cy + y + rows) % rows) * cols + (cx + x + cols) % cols;
U[i] = 0.5; V[i] = 0.25 + Math.random() * 0.05;
}
}
}
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);
// Keep the simulation to about 40,000 cells, whatever the size.
cell = Math.max(scale, Math.sqrt(w * h / 40000));
cols = Math.max(16, Math.ceil(w / cell));
rows = Math.max(16, Math.ceil(h / cell));
U = new Float32Array(cols * rows); V = new Float32Array(cols * rows);
U2 = new Float32Array(cols * rows); V2 = new Float32Array(cols * rows);
off.width = cols; off.height = rows;
img = octx.createImageData(cols, rows);
seed();
}
// One Gray-Scott step. The edges wrap round.
function step() {
for (var y = 0; y < rows; y++) {
var up = ((y + rows - 1) % rows) * cols, mid = y * cols, dn = ((y + 1) % rows) * cols;
for (var x = 0; x < cols; x++) {
var l = x === 0 ? cols - 1 : x - 1, r = x === cols - 1 ? 0 : x + 1, i = mid + x;
var u = U[i], v = V[i];
var lu = 0.2 * (U[mid + l] + U[mid + r] + U[up + x] + U[dn + x]) +
0.05 * (U[up + l] + U[up + r] + U[dn + l] + U[dn + r]) - u;
var lv = 0.2 * (V[mid + l] + V[mid + r] + V[up + x] + V[dn + x]) +
0.05 * (V[up + l] + V[up + r] + V[dn + l] + V[dn + r]) - v;
var uvv = u * v * v;
U2[i] = u + lu - uvv + F * (1 - u);
V2[i] = v + 0.5 * lv + uvv - (F + K) * v;
}
}
var t = U; U = U2; U2 = t;
t = V; V = V2; V2 = t;
}
function draw() {
var data = img.data;
for (var i = 0, j = 0; i < V.length; i++, j += 4) {
// A soft threshold, so the markings have clean edges.
var a = (V[i] - 0.08) / 0.17;
a = a < 0 ? 0 : a > 1 ? 1 : a * a * (3 - 2 * a);
data[j] = cr; data[j + 1] = cg; data[j + 2] = cb; data[j + 3] = a * 255;
}
octx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(off, 0, 0, cols * cell, rows * cell);
}
function advance(dt) {
for (var k = 0; k < speed; k++) step();
clock += dt;
if (loop && !waiting && clock > DEFAULTS.grow) {
waiting = true;
anime.animate(canvas, {
opacity: 0, duration: 900, delay: 1500, ease: 'inOut(2)',
onComplete: function () {
which++;
seed();
waiting = false;
anime.animate(canvas, { opacity: 1, duration: 300 });
}
});
}
}
resize();
if (reduced()) {
// A still frame of the grown pattern.
for (var k = 0; k < 1500; k++) step();
draw();
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
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-reaction-diffusion]').forEach(function (el) {
if (el._rxd) return;
el._rxd = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-reaction-diffusion style="height: 360px; background: #f4dfb8"></div>
<!-- take turns: -->
<div data-reaction-diffusion data-pattern="spots,stripes,maze" data-loop
style="height: 360px; background: #f4dfb8"></div>