Triangle Mesh
Points drift about the box and a mesh of triangles stretches between them, re-knitting itself as they move so the triangles always stay as plump as they can. Each point also bobs gently towards and away from you, so the facets catch the light like a low-poly landscape. The pointer, or a finger, pushes the points aside.
Move or drag to push the points
How to use it
- Paste the snippet just before
</head>. - Add
data-triangle-meshto a box with a height. - Anything inside the box sits on top of the mesh.
Options
Add any of these attributes to the same element.
data-count | Number of drifting points. Default about one per 5,000 square px, 16 to 110. |
data-colors | Three colours across the box. Default #4c1d95,#db2777,#f59e0b. |
data-lines | Colour of the edges, or none. Default rgba(255,255,255,.35). |
data-speed | Speed multiplier. Default 1. |
data-pointer="false" | Ignore the pointer. |
It’s a Delaunay triangulation, rebuilt every frame: no point ever sits inside the circle through any triangle’s three corners, which keeps slivers to a minimum. Points along the edges slide but never leave them, so the mesh always fills the box. It’s paused when off screen, and with reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Triangle Mesh: points drift about the box and a mesh of triangles
// stretches between them, re-knitting itself as they move so the
// triangles always stay as fat as they can (a Delaunay triangulation).
// Each point also bobs up and down out of the screen, so the facets catch
// the light like a low-poly landscape. The pointer (or a finger) pushes
// the points aside. Drawn on a canvas and paused when off screen.
// Usage: <div data-triangle-mesh style="height:360px"></div>
// Optional: data-count (number of points, default about one per 5,000
// square px, 16 to 110), data-colors (three colours across the box,
// default "#4c1d95,#db2777,#f59e0b"), data-lines (colour of the edges,
// default rgba(255,255,255,.35), or "none"), data-speed (speed
// multiplier, default 1), data-pointer="false" (ignore the pointer).
var DEFAULTS = { colors: '#4c1d95,#db2777,#f59e0b', lines: 'rgba(255,255,255,.35)', speed: 1 };
var css =
'[data-triangle-mesh]{position:relative;isolation:isolate;overflow:hidden}' +
'.tms-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 rgb(hex) {
var n = parseInt(hex.trim().replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
// A triangle with its circumcircle, for the Delaunay test.
function tri(P, a, b, c) {
var ax = P[a].x, ay = P[a].y, bx = P[b].x, by = P[b].y, cx = P[c].x, cy = P[c].y;
var dd = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) || 1e-9;
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / dd;
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / dd;
return { a: a, b: b, c: c, x: ux, y: uy, r2: (ax - ux) * (ax - ux) + (ay - uy) * (ay - uy) };
}
// Bowyer-Watson: add the points one at a time, re-knitting round each.
function delaunay(pts, w, h) {
var n = pts.length, m = Math.max(w, h) * 20;
var P = pts.concat([{ x: -m, y: -m }, { x: w / 2, y: m }, { x: w + m, y: -m }]);
var tris = [tri(P, n, n + 1, n + 2)];
for (var i = 0; i < n; i++) {
var p = P[i], keep = [], edges = {};
for (var t = 0; t < tris.length; t++) {
var T = tris[t], dx = p.x - T.x, dy = p.y - T.y;
if (dx * dx + dy * dy < T.r2) {
// This one goes; note its edges (an edge seen twice is inside the hole).
[[T.a, T.b], [T.b, T.c], [T.c, T.a]].forEach(function (e) {
var k = e[0] < e[1] ? e[0] + ',' + e[1] : e[1] + ',' + e[0];
if (edges[k]) edges[k].n++; else edges[k] = { a: e[0], b: e[1], n: 1 };
});
} else keep.push(T);
}
for (var key in edges) if (edges[key].n === 1) keep.push(tri(P, edges[key].a, edges[key].b, i));
tris = keep;
}
return tris.filter(function (T) { return T.a < n && T.b < n && T.c < n; });
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'tms-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
while (cs.length < 3) cs.push(cs[cs.length - 1]);
var lines = (d.lines || DEFAULTS.lines) === 'none' ? null : d.lines || DEFAULTS.lines;
var speed = d.speed !== undefined ? +d.speed || 0 : DEFAULTS.speed;
var usePointer = d.pointer !== 'false';
var w = 0, h = 0, dpr = 1, pts = [], time = 0, pointer = null;
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);
pts = [];
// Points round the edge (corners pinned, the rest slide along it)...
var step = Math.max(50, Math.min(w, h) / 4), nx = Math.max(2, Math.round(w / step)), ny = Math.max(2, Math.round(h / step));
for (var i = 0; i <= nx; i++) {
pts.push(point(i / nx * w, 0, i > 0 && i < nx ? 'x' : 'pin'));
pts.push(point(i / nx * w, h, i > 0 && i < nx ? 'x' : 'pin'));
}
for (var j = 1; j < ny; j++) {
pts.push(point(0, j / ny * h, 'y'));
pts.push(point(w, j / ny * h, 'y'));
}
// ...and the rest free inside.
var n = +d.count || Math.round(Math.max(16, Math.min(110, w * h / 5000)));
for (var k = 0; k < n; k++) pts.push(point(Math.random() * w, Math.random() * h, 'free'));
return true;
}
function point(x, y, kind) {
var a = Math.random() * Math.PI * 2, v = kind === 'pin' ? 0 : 8 + Math.random() * 14;
return { x: x, y: y, hx: x, hy: y, kind: kind, vx: Math.cos(a) * v, vy: Math.sin(a) * v, ph: Math.random() * Math.PI * 2, f: 0.5 + Math.random() * 0.7 };
}
function move(dt) {
var k = dt / 1000 * speed, pad = 6;
time += dt / 1000 * speed;
pts.forEach(function (p) {
if (p.kind === 'pin') return;
if (p.kind !== 'y') {
p.x += p.vx * k;
var lo = p.kind === 'free' ? pad : w * 0.04, hi = p.kind === 'free' ? w - pad : w * 0.96;
if (p.x < lo) { p.x = lo; p.vx = Math.abs(p.vx); }
if (p.x > hi) { p.x = hi; p.vx = -Math.abs(p.vx); }
}
if (p.kind !== 'x') {
p.y += p.vy * k;
var lo2 = p.kind === 'free' ? pad : h * 0.04, hi2 = p.kind === 'free' ? h - pad : h * 0.96;
if (p.y < lo2) { p.y = lo2; p.vy = Math.abs(p.vy); }
if (p.y > hi2) { p.y = hi2; p.vy = -Math.abs(p.vy); }
}
// The pointer pushes free points away.
if (pointer && p.kind === 'free') {
var dx = p.x - pointer.x, dy = p.y - pointer.y, dd = Math.sqrt(dx * dx + dy * dy) || 1, r = 90;
if (dd < r) {
var push = (1 - dd / r) * 160 * dt / 1000;
p.x = Math.max(pad, Math.min(w - pad, p.x + dx / dd * push));
p.y = Math.max(pad, Math.min(h - pad, p.y + dy / dd * push));
}
}
});
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var tris = delaunay(pts, w, h), amp = Math.min(w, h) * 0.18;
// Each point's height out of the screen.
pts.forEach(function (p) { p.z = Math.sin(p.ph + time * p.f) * amp; });
ctx.lineJoin = 'round';
tris.forEach(function (T) {
var A = pts[T.a], B = pts[T.b], C = pts[T.c];
// Base colour by where the facet is: across, then down.
var u = Math.max(0, Math.min(1, ((A.x + B.x + C.x) / 3 / w) * 0.65 + ((A.y + B.y + C.y) / 3 / h) * 0.35));
var c0 = u < 0.5 ? cs[0] : cs[1], c1 = u < 0.5 ? cs[1] : cs[2], f = u < 0.5 ? u * 2 : u * 2 - 1;
// Light from the top left: the facet's normal decides how bright it is.
var ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
var nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
var len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
if (nz < 0) { nx = -nx; ny = -ny; nz = -nz; }
var lit = 0.72 + 0.5 * ((-nx * 0.5 - ny * 0.5 + nz * 0.7) / len);
var r = Math.min(255, (c0[0] + (c1[0] - c0[0]) * f) * lit) | 0;
var g = Math.min(255, (c0[1] + (c1[1] - c0[1]) * f) * lit) | 0;
var b = Math.min(255, (c0[2] + (c1[2] - c0[2]) * f) * lit) | 0;
ctx.fillStyle = ctx.strokeStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
ctx.beginPath();
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
ctx.lineTo(C.x, C.y);
ctx.closePath();
ctx.fill();
// A hairline in the same colour hides the seams between facets.
ctx.lineWidth = 0.6;
ctx.stroke();
});
if (!lines) return;
ctx.strokeStyle = lines;
ctx.lineWidth = 0.8;
ctx.beginPath();
tris.forEach(function (T) {
ctx.moveTo(pts[T.a].x, pts[T.a].y);
ctx.lineTo(pts[T.b].x, pts[T.b].y);
ctx.lineTo(pts[T.c].x, pts[T.c].y);
ctx.closePath();
});
ctx.stroke();
ctx.fillStyle = lines;
pts.forEach(function (p) {
if (p.kind !== 'free') return;
ctx.beginPath();
ctx.arc(p.x, p.y, 1.8, 0, Math.PI * 2);
ctx.fill();
});
}
resize();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
if (reduced()) return;
if (usePointer) {
var at = function (e) {
var r = el.getBoundingClientRect();
pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
};
el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) at(e); });
el.addEventListener('pointerdown', at);
el.addEventListener('pointerleave', function () { pointer = null; });
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; });
el.addEventListener('pointercancel', function () { pointer = null; });
}
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;
move(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-triangle-mesh]').forEach(function (el) {
if (el._tms) return;
el._tms = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-triangle-mesh style="height: 360px"></div>