Morphing Tessellation
A wall of interlocking tiles, Escher-style, slowly changes shape. Squares lean into diamonds and grow into hexagons, and their edges bulge, dent and wiggle, but every bump on one side is matched by a dent on the neighbour, so the tiles always fit together with no gaps. Tap or click to morph to a new shape straight away.
How to use it
- Paste the snippet just before
</head>. - Add
data-morphing-tessellationto a box with a height.
Options
Add any of these attributes to the same element.
data-mode | any, square or hex: the outline the tiles are built on. Default any, which wanders between squares, parallelograms and hexagons. |
data-size | Tile size in px. Default scaled to the box. |
data-duration | How long each change of shape takes. Default 2400 ms. |
data-hold | How long each shape stays put. Default 900 ms. |
data-colors | Comma-separated tile colours; three looks best. Default #7c3aed,#db2777,#f59e0b. |
data-line | Colour of the seams. Default #1a1033. |
data-drift="false" | Keep the wall still instead of slowly sliding. |
Each tile is a hexagon whose opposite sides are the same curve, shifted across; squeeze one pair of sides to nothing and it’s a parallelogram. Any tile like that fills the plane by sliding copies of itself, so the tiles fit whatever the curves do. It’s paused when off screen, and with reduced motion it shows a still pattern.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Morphing Tessellation: a wall of interlocking tiles, Escher-style, that
// slowly change shape. Squares lean into diamonds and grow into
// hexagons, and their edges bulge, dent and wiggle, but every bump on one
// side is matched by a dent on the neighbour, so the tiles always fit
// together with no gaps. Tap or click to morph to a new shape straight away.
// Usage: <div data-morphing-tessellation style="height:360px;background:#1a1033"></div>
// Optional: data-mode (any, square or hex: the outline the tiles are built
// on; default any, which wanders between squares, parallelograms and
// hexagons), data-size (tile size in px, default scaled to the box),
// data-duration (ms each change of shape takes, default 2400), data-hold
// (ms each shape stays put, default 900), data-colors (comma-separated tile
// colours, three looks best, default violet, pink and amber),
// data-line (colour of the seams, default #1a1033), data-drift="false"
// (keep the wall still instead of slowly sliding).
var DEFAULTS = { mode: 'any', duration: 2400, hold: 900, colors: '#7c3aed,#db2777,#f59e0b', line: '#1a1033' };
var SAMPLES = 12; // points along each edge
var css =
'[data-morphing-tessellation]{position:relative;isolation:isolate;overflow:hidden;cursor:pointer;touch-action:manipulation}' +
'.mts-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 rand(a, b) { return a + Math.random() * (b - a); }
var DEG = Math.PI / 180;
// A tile shape: three edge vectors (length, angle), each with a curve made
// of three sine waves. The hexagon's other three edges are the same three,
// reversed, which is what makes neighbours fit. A zero-length third edge
// turns the hexagon into a parallelogram.
function newShape(mode) {
var s = { len: [], ang: [], amp: [] };
var a0 = rand(-12, 12), lenC;
if (mode === 'square') {
s.ang = [a0 * DEG, (a0 + rand(70, 100)) * DEG, (a0 + 135) * DEG];
s.len = [rand(0.85, 1.1), rand(0.85, 1.1), 0];
} else if (mode === 'hex') {
var b0 = a0 + rand(50, 70);
s.ang = [a0 * DEG, b0 * DEG, (b0 + rand(50, 70)) * DEG];
s.len = [rand(0.55, 0.75), rand(0.55, 0.75), rand(0.55, 0.75)];
} else {
var hex = Math.random() < 0.6;
var b1 = a0 + (hex ? rand(50, 70) : rand(65, 105));
lenC = hex ? rand(0.4, 0.75) : 0;
s.ang = [a0 * DEG, b1 * DEG, Math.min(a0 + 165, b1 + rand(50, 75)) * DEG];
s.len = hex ? [rand(0.5, 0.8), rand(0.5, 0.8), lenC] : [rand(0.8, 1.1), rand(0.8, 1.1), 0];
}
for (var i = 0; i < 3; i++) {
var flat = Math.random() < 0.15;
s.amp.push(flat ? [0, 0, 0] : [rand(-0.24, 0.24), rand(-0.13, 0.13), rand(-0.06, 0.06)]);
}
return s;
}
function mix(a, b, t) {
var out = { len: [], ang: [], amp: [] };
for (var i = 0; i < 3; i++) {
out.len.push(a.len[i] + (b.len[i] - a.len[i]) * t);
out.ang.push(a.ang[i] + (b.ang[i] - a.ang[i]) * t);
out.amp.push([0, 1, 2].map(function (k) { return a.amp[i][k] + (b.amp[i][k] - a.amp[i][k]) * t; }));
}
return out;
}
function hex2rgb(c) {
c = c.replace('#', '');
if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
var n = parseInt(c, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'mts-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var mode = d.mode || DEFAULTS.mode;
var duration = +d.duration || DEFAULTS.duration;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var shades = colors.map(function (c) {
var rgb = hex2rgb(c);
return 'rgb(' + rgb.map(function (v) { return Math.round(v + (255 - v) * 0.22); }).join(',') + ')';
});
var line = d.line || DEFAULTS.line;
var drift = d.drift !== 'false';
var w = 0, h = 0, dpr = 1, size = 60;
var from = newShape(mode), to = newShape(mode), t = 0, clock = 0, panX = 0, panY = 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);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
size = +d.size || Math.max(40, Math.min(96, Math.min(w, h) / 3.4));
return true;
}
// The outline of one tile, as a list of points, starting at its corner (0, 0).
function outline(s) {
var e = [], i, k, u;
for (i = 0; i < 3; i++) e.push([Math.cos(s.ang[i]) * s.len[i] * size, Math.sin(s.ang[i]) * s.len[i] * size]);
var pts = [], x = 0, y = 0;
function bump(amp, v) { return amp[0] * Math.sin(Math.PI * v) + amp[1] * Math.sin(2 * Math.PI * v) + amp[2] * Math.sin(3 * Math.PI * v); }
for (var side = 0; side < 6; side++) {
i = side % 3;
var dir = side < 3 ? 1 : -1, ex = e[i][0] * dir, ey = e[i][1] * dir;
// The offset always points the same way for a pair of edges, so they match.
var nx = -e[i][1], ny = e[i][0];
for (k = 0; k < SAMPLES; k++) {
u = k / SAMPLES;
var f = bump(s.amp[i], side < 3 ? u : 1 - u);
pts.push(x + ex * u + nx * f, y + ey * u + ny * f);
}
x += ex; y += ey;
}
return { pts: pts, t1: [e[0][0] + e[1][0], e[0][1] + e[1][1]], t2: [e[1][0] + e[2][0], e[1][1] + e[2][1]], mid: [(e[0][0] + e[1][0] + e[2][0]) / 2, (e[0][1] + e[1][1] + e[2][1]) / 2] };
}
function draw(s) {
ctx.fillStyle = line;
ctx.fillRect(0, 0, w, h);
var o = outline(s), t1 = o.t1, t2 = o.t2;
var det = t1[0] * t2[1] - t1[1] * t2[0];
if (Math.abs(det) < 1) return;
// Which lattice positions (m, n) can touch the box?
var cx = w / 2 + panX, cy = h / 2 + panY;
var mMin = Infinity, mMax = -Infinity, nMin = Infinity, nMax = -Infinity;
[[0, 0], [w, 0], [0, h], [w, h]].forEach(function (p) {
var px = p[0] - cx, py = p[1] - cy;
var m = (px * t2[1] - py * t2[0]) / det, n = (t1[0] * py - t1[1] * px) / det;
mMin = Math.min(mMin, m); mMax = Math.max(mMax, m); nMin = Math.min(nMin, n); nMax = Math.max(nMax, n);
});
mMin = Math.floor(mMin) - 2; mMax = Math.ceil(mMax) + 1; nMin = Math.floor(nMin) - 2; nMax = Math.ceil(nMax) + 1;
if ((mMax - mMin) * (nMax - nMin) > 4000) return;
var pts = o.pts, lw = Math.max(1.5, size / 30);
ctx.lineJoin = 'round';
ctx.lineWidth = lw;
ctx.strokeStyle = line;
for (var m = mMin; m <= mMax; m++) {
for (var n = nMin; n <= nMax; n++) {
var bx = cx + m * t1[0] + n * t2[0] - o.mid[0], by = cy + m * t1[1] + n * t2[1] - o.mid[1];
var ci = (((m - n) % colors.length) + colors.length) % colors.length;
ctx.beginPath();
ctx.moveTo(bx + pts[0], by + pts[1]);
for (var k = 2; k < pts.length; k += 2) ctx.lineTo(bx + pts[k], by + pts[k + 1]);
ctx.closePath();
var g = ctx.createLinearGradient(bx, by - size * 0.4, bx, by + size * 0.9);
g.addColorStop(0, shades[ci]);
g.addColorStop(1, colors[ci]);
ctx.fillStyle = g;
ctx.fill();
ctx.stroke();
}
}
}
function ease(x) { return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; }
resize();
if (reduced()) {
draw(from);
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(from); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(mix(from, to, ease(t))); }).observe(el);
el.addEventListener('pointerdown', function () {
// Start a fresh morph from wherever the tiles are right now.
from = mix(from, to, ease(t));
to = newShape(mode);
t = 0; clock = 0;
});
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(100, now - last) : 16;
last = now;
clock += dt;
if (clock < hold) t = 0;
else t = Math.min(1, (clock - hold) / duration);
if (t >= 1) { from = to; to = newShape(mode); t = 0; clock = 0; }
if (drift) { panX += dt * 0.012; panY += dt * 0.006; }
draw(mix(from, to, ease(t)));
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-morphing-tessellation]').forEach(function (el) {
if (el._mts) return;
el._mts = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-morphing-tessellation style="height: 360px"></div>
<!-- hexagons only, in two colours: -->
<div data-morphing-tessellation data-mode="hex" data-colors="#0ea5e9,#6366f1"
style="height: 360px"></div>