Elementary Automaton
A row of cells grows down the box one line at a time. Each new cell looks at the three above it, and a rule number says whether it lives: Rule 30 makes chaos, Rule 110 makes interlocking triangles, Rule 90 makes Sierpiński’s triangle.
How to use it
- Paste the snippet just before
</head>. - Add
data-elementary-automatonto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-rule | Rule number, 0 to 255. Default 30. A comma-separated list such as 30,110,90 takes turns with data-loop. |
data-loop | When the box fills, fade and start again with the next rule. Without it, the pattern keeps scrolling up. |
data-speed | Rows per second. Default 30. |
data-cell | Cell size in px. Default fits about 110 cells across. |
data-colors | Two colours the rows drift between. Default #a855f7,#ec4899. |
data-start | single (one live cell) or random. Default single. |
data-label="false" | Hide the rule number in the corner. |
The rule’s eight bits say what happens to each of the eight possible patterns of three parent cells. The edges wrap round. It’s paused when off screen, and with reduced motion the box fills in one go.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Elementary Automaton: a one-dimensional cellular automaton grows down
// the box row by row. Each new cell looks at the three cells above it and
// a rule number (0 to 255) says whether it lives. Rule 30 makes chaos,
// Rule 110 makes interlocking triangles, Rule 90 makes Sierpinski's
// triangle. Once the box is full it keeps scrolling. Paused when off screen.
// Usage: <div data-elementary-automaton style="height:360px"></div>
// Optional: data-rule (default 30; a comma-separated list such as
// "30,110,90" takes turns with data-loop), data-loop (when the box fills,
// fade and start again with the next rule instead of scrolling),
// data-speed (rows per second, default 30), data-cell (cell size in px,
// default fits about 110 cells across), data-colors (two colours the rows
// drift between, default "#a855f7,#ec4899"), data-start ("single", one
// live cell, or "random"; default single), data-label="false" (hide the
// rule number).
var DEFAULTS = { rule: '30', speed: 30, colors: '#a855f7,#ec4899', start: 'single' };
var css =
'[data-elementary-automaton]{position:relative;isolation:isolate;overflow:hidden}' +
'.eca-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}' +
'.eca-label{position:absolute;right:10px;bottom:8px;z-index:1;padding:3px 9px;border-radius:999px;' +
'background:rgba(15,10,30,.6);color:#fff;font:600 12px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var n = parseInt(hex.trim().replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'eca-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var rules = String(d.rule || DEFAULTS.rule).split(',').map(function (r) { return (+r & 255); });
var which = 0;
var interval = 1000 / (+d.speed || DEFAULTS.speed);
var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
if (cs.length < 2) cs.push(cs[0]);
var random = (d.start || DEFAULTS.start) === 'random';
var loop = d.loop !== undefined && d.loop !== 'false';
var label = null;
if (d.label !== 'false') {
label = document.createElement('span');
label.className = 'eca-label';
label.setAttribute('aria-hidden', 'true');
el.appendChild(label);
}
var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, size = 4, ox = 0;
var row, lines, gen = 0, acc = 0, waiting = false;
function rule() { return rules[which % rules.length]; }
function reset() {
row = new Uint8Array(cols);
if (random) for (var x = 0; x < cols; x++) row[x] = Math.random() < 0.5 ? 1 : 0;
// Rule 110 (and its mirror, 124) grows sideways: start it near an edge.
else row[rule() === 110 ? cols - 2 : rule() === 124 ? 1 : cols >> 1] = 1;
gen = 0; acc = 0;
lines = [{ cells: row, gen: 0 }];
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (label) label.textContent = 'Rule ' + rule();
paint(row, 0, 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);
size = +d.cell || Math.max(2, Math.round(w / 110));
cols = Math.ceil(w / size);
rows = Math.ceil(h / size);
ox = (w - cols * size) / 2;
reset();
}
// Draw a row at line y, in a colour that drifts with its generation.
function paint(cells, g, y) {
var k = (Math.sin(g / 18) + 1) / 2, a = cs[0], b = cs[1];
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + Math.round(a[0] + (b[0] - a[0]) * k) + ',' + Math.round(a[1] + (b[1] - a[1]) * k) + ',' + Math.round(a[2] + (b[2] - a[2]) * k) + ')';
var gap = size > 3 ? 0.5 : 0;
for (var x = 0; x < cols; x++) if (cells[x]) ctx.fillRect(ox + x * size, y * size, size - gap, size - gap);
}
// The next row: each cell reads its three parents as a number 0-7 and
// looks up that bit of the rule.
function next() {
var r = rule(), out = new Uint8Array(cols);
for (var x = 0; x < cols; x++) {
var n = row[(x + cols - 1) % cols] << 2 | row[x] << 1 | row[(x + 1) % cols];
out[x] = r >> n & 1;
}
row = out;
gen++;
}
function addRow() {
if (lines.length < rows) {
next();
lines.push({ cells: row, gen: gen });
paint(row, gen, lines.length - 1);
return;
}
if (loop) {
if (waiting) return;
waiting = true;
setTimeout(function () {
anime.animate(canvas, {
opacity: 0, duration: 600, ease: 'inOut(2)',
onComplete: function () {
which++;
reset();
waiting = false;
anime.animate(canvas, { opacity: 1, duration: 300 });
}
});
}, 1600);
return;
}
// Full: drop the top row and redraw the box one row higher.
next();
lines.push({ cells: row, gen: gen });
lines.shift();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (var i = 0; i < lines.length; i++) paint(lines[i].cells, lines[i].gen, i);
}
function fill() {
while (lines.length < rows) addRow();
}
resize();
if (reduced()) {
// A still frame: the box filled in one go.
fill();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); fill(); }).observe(el);
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;
acc += dt;
while (acc >= interval) { acc -= interval; addRow(); }
}
});
// 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-elementary-automaton]').forEach(function (el) {
if (el._eca) return;
el._eca = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-elementary-automaton style="height: 360px; background: #0f0a1e"></div>
<!-- take turns, starting again each time it fills: -->
<div data-elementary-automaton data-rule="30,110,90" data-loop
style="height: 360px; background: #0f0a1e"></div>