Penrose Tiling
Fat and thin rhombs fit together into a pattern that covers the box but never repeats. It grows outwards from a ten-pointed star in the middle, tile by tile, each one popping into place. Loop it and the tiles fall away from the edges in, then it grows again at a new angle.
How to use it
- Paste the snippet just before
</head>. - Add
data-penrose-tilingto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-size | Rough length of a tile’s edge in px. Default 26. |
data-colors | Fat rhomb, thin rhomb and line colours. Default #7c3aed,#f472b6,#1a1033. |
data-duration | Time to fill the box. Default 5000 ms. |
data-loop | Hold, let the tiles fall away and grow again. |
It’s Roger Penrose’s P3 tiling, made by repeatedly cutting a wheel of triangles into smaller ones by the golden ratio and pairing the halves into rhombs. It’s paused when off screen, and with reduced motion it shows the whole tiling.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Penrose Tiling: a tiling of fat and thin rhombs that covers the box
// without ever repeating. It grows outwards from a ten-fold star in the
// middle, tile by tile, each one popping into place. With data-loop it
// holds, the tiles fall away from the edges inwards, and it grows again
// at a new angle.
// Usage: <div data-penrose-tiling style="height:360px"></div>
// Optional: data-size (rough tile edge in px, default 26), data-colors
// (fat rhomb, thin rhomb and line colours, default
// "#7c3aed,#f472b6,#1a1033"), data-duration (ms to fill the box,
// default 5000), data-loop (fall away and grow again).
var DEFAULTS = { size: 26, colors: '#7c3aed,#f472b6,#1a1033', duration: 5000, hold: 2400 };
var PHI = (1 + Math.sqrt(5)) / 2;
var css =
'[data-penrose-tiling]{position:relative;isolation:isolate;overflow:hidden}' +
'.pnr-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; }
// Split each Robinson triangle into smaller ones (the P3 "deflation").
// A triangle is [kind, A, B, C], points as [x, y].
function subdivide(tris) {
var out = [];
tris.forEach(function (t) {
var A = t[1], B = t[2], C = t[3];
if (t[0] === 0) {
var P = [A[0] + (B[0] - A[0]) / PHI, A[1] + (B[1] - A[1]) / PHI];
out.push([0, C, P, B], [1, P, C, A]);
} else {
var Q = [B[0] + (A[0] - B[0]) / PHI, B[1] + (A[1] - B[1]) / PHI];
var R = [B[0] + (C[0] - B[0]) / PHI, B[1] + (C[1] - B[1]) / PHI];
out.push([1, R, C, A], [1, Q, R, B], [0, R, Q, A]);
}
});
return out;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'pnr-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var size = +d.size || DEFAULTS.size;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var duration = +d.duration || DEFAULTS.duration;
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, tiles = [], clock = 0, phase = 'grow', phaseAt = 0, turn = 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);
return true;
}
function make() {
var cx = w / 2, cy = h / 2;
var R = Math.sqrt(w * w + h * h) / 2 * 1.15;
var depth = Math.max(2, Math.ceil(Math.log(R / size) / Math.log(PHI)));
var tris = [];
// A wheel of ten triangles round the middle.
for (var i = 0; i < 10; i++) {
var B = [cx + R * Math.cos((2 * i - 1) * Math.PI / 10 + turn), cy + R * Math.sin((2 * i - 1) * Math.PI / 10 + turn)];
var C = [cx + R * Math.cos((2 * i + 1) * Math.PI / 10 + turn), cy + R * Math.sin((2 * i + 1) * Math.PI / 10 + turn)];
if (i % 2 === 0) { var s = B; B = C; C = s; }
tris.push([0, [cx, cy], B, C]);
}
for (var k = 0; k < depth; k++) tris = subdivide(tris);
// Pair the two halves of each rhomb: they share the edge B-C.
var halves = {};
tiles = [];
tris.forEach(function (t) {
var B = t[2], C = t[3];
var k1 = Math.round(B[0] * 10) + ',' + Math.round(B[1] * 10), k2 = Math.round(C[0] * 10) + ',' + Math.round(C[1] * 10);
var key = t[0] + '|' + (k1 < k2 ? k1 + '|' + k2 : k2 + '|' + k1);
if (halves[key]) {
var o = halves[key];
delete halves[key];
tiles.push({ kind: t[0], pts: [t[1], B, o[1], C] });
} else halves[key] = t;
});
Object.keys(halves).forEach(function (key) {
var t = halves[key];
tiles.push({ kind: t[0], pts: [t[1], t[2], t[3]] });
});
// Keep the tiles that touch the box, nearest the middle first.
var pad = size * 2;
tiles = tiles.filter(function (t) {
var mx = 0, my = 0;
t.pts.forEach(function (p) { mx += p[0]; my += p[1]; });
t.x = mx / t.pts.length;
t.y = my / t.pts.length;
return t.x > -pad && t.x < w + pad && t.y > -pad && t.y < h + pad;
});
tiles.forEach(function (t) {
t.d = Math.sqrt((t.x - cx) * (t.x - cx) + (t.y - cy) * (t.y - cy)) + Math.random() * size * 0.8;
t.shade = Math.random() * 0.18 - 0.09;
});
tiles.sort(function (a, b) { return a.d - b.d; });
var far = tiles.length ? tiles[tiles.length - 1].d : 1;
tiles.forEach(function (t, i) {
// Spread the arrivals so the front moves at a steady pace.
t.at = Math.sqrt(i / tiles.length) * 0.35 * duration + (t.d / far) * 0.65 * duration;
});
}
function start() {
make();
clock = 0;
phase = 'grow';
}
function easeOutBack(x) {
var c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(1, size * 0.07);
ctx.strokeStyle = colors[2] || '#1a1033';
var far = tiles.length ? tiles[tiles.length - 1].d : 1;
for (var i = 0; i < tiles.length; i++) {
var t = tiles[i], p;
if (phase === 'leave') {
// Fall away from the edges inwards.
var outAt = (1 - t.d / far) * 1100;
p = 1 - Math.min(1, Math.max(0, (clock - phaseAt - outAt) / 350));
p = p * p;
} else {
p = Math.min(1, Math.max(0, (clock - t.at) / 420));
if (p < 1) p = easeOutBack(p);
}
if (p <= 0) continue;
ctx.globalAlpha = Math.min(1, p * 1.5);
ctx.fillStyle = t.kind === 1 ? colors[0] : colors[1] || colors[0];
ctx.beginPath();
for (var k = 0; k < t.pts.length; k++) {
var x = t.x + (t.pts[k][0] - t.x) * p, y = t.y + (t.pts[k][1] - t.y) * p;
if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
// A light or dark wash, so the tiles aren't flat.
ctx.fillStyle = t.shade > 0 ? 'rgba(255,255,255,' + t.shade.toFixed(3) + ')' : 'rgba(0,0,0,' + (-t.shade).toFixed(3) + ')';
ctx.fill();
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function tick(dt) {
clock += dt;
if (phase === 'grow' && clock > duration + 500) { phase = 'hold'; phaseAt = clock; }
else if (phase === 'hold' && loop && clock - phaseAt > DEFAULTS.hold) { phase = 'leave'; phaseAt = clock; }
else if (phase === 'leave' && clock - phaseAt > 1600) { turn += Math.PI / 7; start(); }
}
resize();
start();
if (reduced()) {
clock = duration * 2;
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { make(); draw(); } }).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-penrose-tiling]').forEach(function (el) {
if (el._pnr) return;
el._pnr = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-penrose-tiling data-loop style="height: 360px; background: #1a1033"></div>