Mondrian Subdivision
The box splits into rectangles, one black line at a time, each line drawing itself across a rectangle at a random point. Once it’s busy enough, a few of the rectangles fill with red, blue and yellow, like a Piet Mondrian painting. Loop it and it wipes clean and paints another.
How to use it
- Paste the snippet just before
</head>. - Add
data-mondrian-subdivisionto a box with a height.
Options
Add any of these attributes to the same element.
data-splits | How many lines. Default about one per 9,000 square px, 6 to 24. |
data-colors | Fill colours. Default #d6271c,#1d4fa8,#f2c318. |
data-paper | Background colour. Default #f4efe3. |
data-ink | Line colour. Default #111111. |
data-line | Line width in px. Default about 2% of the box’s shorter side. |
data-speed | Speed multiplier. Default 1. |
data-loop | Hold the painting, wipe it and paint another. |
Bigger rectangles are likelier to be split, and never in the exact middle, which keeps the balance lopsided the way Mondrian liked it. No two touching rectangles get the same colour. It’s paused when off screen, and with reduced motion it shows a finished painting.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Mondrian Subdivision: the box splits into rectangles, one black line at
// a time, each line drawing itself across a rectangle at a random point.
// Once it's busy enough, a few of the rectangles fill with red, blue and
// yellow, like a Piet Mondrian painting. With data-loop it holds, wipes
// clean and paints another.
// Usage: <div data-mondrian-subdivision style="height:360px"></div>
// Optional: data-splits (how many lines, default about one per 9,000
// square px, 6 to 24), data-colors (fill colours, default
// "#d6271c,#1d4fa8,#f2c318"), data-paper (background, default #f4efe3),
// data-ink (line colour, default #111), data-line (line width in px,
// default about 1% of the box), data-speed (speed multiplier, default 1),
// data-loop (wipe clean and paint another).
var DEFAULTS = { colors: '#d6271c,#1d4fa8,#f2c318', paper: '#f4efe3', ink: '#111111', speed: 1, hold: 2600 };
var css =
'[data-mondrian-subdivision]{position:relative;isolation:isolate;overflow:hidden}' +
'.mnd-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 = 'mnd-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var paper = d.paper || DEFAULTS.paper, ink = d.ink || DEFAULTS.ink;
var speed = +d.speed || DEFAULTS.speed;
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, lw = 4, minSide = 30, total = 10;
// rects: {x, y, w, h, fill, fillAt}; lines: {x1, y1, x2, y2, at}
var rects = [], lines = [], queue = [], clock = 0, phase = 'split', phaseAt = 0, made = 0;
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);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
lw = +d.line || Math.max(3, Math.round(Math.min(w, h) * 0.022));
minSide = Math.max(lw * 5, Math.min(w, h) * 0.09);
total = +d.splits || Math.max(6, Math.min(24, Math.round(w * h / 9000)));
return true;
}
function start() {
rects = [{ x: 0, y: 0, w: w, h: h, fill: null }];
lines = [];
queue = [];
made = 0;
clock = 0;
phase = 'split';
phaseAt = 0;
}
// Split one rectangle, picked at random with the big ones likelier.
function split() {
var pool = rects.filter(function (r) { return r.w > minSide * 2 || r.h > minSide * 2; });
if (!pool.length) return false;
var sum = 0;
pool.forEach(function (r) { sum += Math.pow(r.w * r.h, 1.3); });
var pick = Math.random() * sum, r = pool[0];
for (var i = 0; i < pool.length; i++) {
pick -= Math.pow(pool[i].w * pool[i].h, 1.3);
if (pick <= 0) { r = pool[i]; break; }
}
// Split across the longer side mostly, at an off-centre point.
var vertical = r.w > minSide * 2 && (r.h <= minSide * 2 || Math.random() < r.w / (r.w + r.h) * 1.2);
var len = vertical ? r.w : r.h;
var t = minSide + Math.random() * (len - minSide * 2);
if (Math.abs(t - len / 2) < len * 0.08) t += (Math.random() < 0.5 ? -1 : 1) * len * 0.12;
t = Math.round(t);
var i2 = rects.indexOf(r), a, b, line;
if (vertical) {
a = { x: r.x, y: r.y, w: t, h: r.h, fill: null };
b = { x: r.x + t, y: r.y, w: r.w - t, h: r.h, fill: null };
line = Math.random() < 0.5 ? { x1: r.x + t, y1: r.y, x2: r.x + t, y2: r.y + r.h } : { x1: r.x + t, y1: r.y + r.h, x2: r.x + t, y2: r.y };
} else {
a = { x: r.x, y: r.y, w: r.w, h: t, fill: null };
b = { x: r.x, y: r.y + t, w: r.w, h: r.h - t, fill: null };
line = Math.random() < 0.5 ? { x1: r.x, y1: r.y + t, x2: r.x + r.w, y2: r.y + t } : { x1: r.x + r.w, y1: r.y + t, x2: r.x, y2: r.y + t };
}
line.at = clock;
lines.push(line);
rects.splice(i2, 1, a, b);
return true;
}
// Colour about a quarter of the rectangles, never two neighbours alike.
function paint() {
var order = rects.slice().sort(function () { return Math.random() - 0.5; });
var want = Math.max(3, Math.round(rects.length * 0.28)), n = 0, at = clock;
order.forEach(function (r) {
if (n >= want) return;
var c = colors[n % colors.length];
var clash = rects.some(function (o) {
return o.fill === c && o.x <= r.x + r.w && r.x <= o.x + o.w && o.y <= r.y + r.h && r.y <= o.y + o.h;
});
if (clash) return;
r.fill = c;
r.fillAt = at + n * 220;
n++;
});
// Now and then, one small black block.
var small = rects.filter(function (r) { return !r.fill && r.w * r.h < w * h * 0.02; });
if (small.length && Math.random() < 0.7) {
var s = small[Math.floor(Math.random() * small.length)];
s.fill = ink;
s.fillAt = at + n * 220;
}
}
function tick(dt) {
clock += dt * speed;
if (phase === 'split') {
if (clock - phaseAt > 330) {
phaseAt = clock;
if (made < total && split()) made++;
else { phase = 'paint'; paint(); }
}
} else if (phase === 'paint' && clock - phaseAt > 1500 + rects.length * 60) {
phase = 'hold';
phaseAt = clock;
} else if (phase === 'hold' && loop && clock - phaseAt > DEFAULTS.hold) {
phase = 'wipe';
phaseAt = clock;
} else if (phase === 'wipe' && clock - phaseAt > 700) {
start();
}
}
function ease(x) { return 1 - Math.pow(1 - x, 3); }
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = paper;
ctx.fillRect(0, 0, w, h);
var wipe = phase === 'wipe' ? ease(Math.min(1, (clock - phaseAt) / 600)) : 0;
ctx.save();
if (wipe) {
// The painting slides away downwards as fresh paper rolls in.
ctx.globalAlpha = 1 - wipe;
ctx.translate(0, wipe * h * 0.15);
}
// Colour blocks fill from one corner.
rects.forEach(function (r) {
if (!r.fill || clock < r.fillAt) return;
var p = ease(Math.min(1, (clock - r.fillAt) / 450));
ctx.fillStyle = r.fill;
ctx.fillRect(r.x, r.y + r.h * (1 - p), r.w, r.h * p);
});
// Lines draw themselves across.
ctx.fillStyle = ink;
lines.forEach(function (l) {
var p = ease(Math.min(1, (clock - l.at) / 300));
var x2 = l.x1 + (l.x2 - l.x1) * p, y2 = l.y1 + (l.y2 - l.y1) * p;
var x = Math.min(l.x1, x2) - lw / 2, y = Math.min(l.y1, y2) - lw / 2;
ctx.fillRect(x, y, Math.abs(x2 - l.x1) + lw, Math.abs(y2 - l.y1) + lw);
});
ctx.restore();
// A frame round the edge.
ctx.strokeStyle = ink;
ctx.lineWidth = lw;
ctx.strokeRect(lw / 2, lw / 2, w - lw, h - lw);
}
function still() {
start();
while (made < total && split()) made++;
paint();
clock += 100000;
lines.forEach(function (l) { l.at = 0; });
phase = 'hold';
draw();
}
resize();
start();
if (reduced()) {
still();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) still(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) start(); }).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;
tick(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-mondrian-subdivision]').forEach(function (el) {
if (el._mnd) return;
el._mnd = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-mondrian-subdivision data-loop style="height: 360px"></div>