Voronoi Cells
Seed points drift about the box, and each one owns every spot that’s nearer to it than to any other seed. As the seeds wander, the cells swell, shrink and trade edges, like stained glass that won’t keep still. Move the pointer over it, or press on a phone, and you become a seed too.
Move or press to add a seed
How to use it
- Paste the snippet just before
</head>. - Add
data-voronoi-cellsto a box with a height. - Anything inside the box sits on top of the cells.
Options
Add any of these attributes to the same element.
data-count | Number of seeds. Default about one per 7,000 square px, 10 to 60. |
data-colors | Comma-separated cell colours. Default pastel violets, pinks, blues and golds. |
data-gap | Colour of the lines between cells. Default #1a1033. |
data-speed | Speed multiplier. Default 1. |
data-seeds="false" | Hide the seed dots. |
data-pointer="false" | Ignore the pointer. |
Each cell is worked out by cutting the box in half along the line midway between its seed and every other seed, and keeping its seed’s side. 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 () {
// Voronoi Cells: seed points drift about the box, and each one owns every
// spot that's nearer to it than to any other seed. The cells swell,
// shrink and trade edges as their seeds wander, like stained glass that
// won't keep still. Move the pointer over it (or press on a phone) and it
// becomes a seed too. Drawn on a canvas and paused when off screen.
// Usage: <div data-voronoi-cells style="height:360px"></div>
// Optional: data-count (number of seeds, default about one per
// 7,000 square px, 10 to 60), data-colors (comma-separated cell colours,
// default pastel violets, pinks, blues and golds), data-gap (colour of the
// lines between cells, default #1a1033), data-speed (speed multiplier,
// default 1), data-seeds="false" (hide the seed dots), data-pointer=
// "false" (ignore the pointer).
var DEFAULTS = { colors: '#c4b5fd,#f9a8d4,#93c5fd,#fde68a,#a7f3d0,#ddd6fe,#fbcfe8,#bae6fd', gap: '#1a1033', speed: 1 };
var css =
'[data-voronoi-cells]{position:relative;isolation:isolate;overflow:hidden}' +
'.vrn-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; }
// Keep the part of a polygon where nx*x + ny*y <= c.
function clip(poly, nx, ny, c) {
var out = [], n = poly.length;
for (var i = 0; i < n; i++) {
var a = poly[i], b = poly[(i + 1) % n];
var da = nx * a[0] + ny * a[1] - c, db = nx * b[0] + ny * b[1] - c;
if (da <= 0) out.push(a);
if ((da <= 0) !== (db <= 0)) {
var t = da / (da - db);
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
}
return out;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'vrn-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 gap = d.gap || DEFAULTS.gap;
var speed = d.speed !== undefined ? +d.speed || 0 : DEFAULTS.speed;
var showSeeds = d.seeds !== 'false';
var usePointer = d.pointer !== 'false';
var w = 0, h = 0, dpr = 1, seeds = [], pointer = null;
function seed(i) {
var a = Math.random() * Math.PI * 2;
return { x: Math.random() * w, y: Math.random() * h, a: a, v: 10 + Math.random() * 14, color: colors[i % colors.length] };
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
var sx = w ? nw / w : 1, sy = h ? nh / h : 1;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
var n = +d.count || Math.round(Math.max(10, Math.min(60, w * h / 7000)));
// Keep the seeds we have (stretched to the new size), add or drop the rest.
seeds.forEach(function (s) { s.x *= sx; s.y *= sy; });
while (seeds.length < n) seeds.push(seed(seeds.length));
seeds.length = n;
return true;
}
function move(dt) {
var k = dt / 1000 * speed;
seeds.forEach(function (s) {
// Wander: the heading turns a little at random.
s.a += (Math.random() - 0.5) * 2.4 * k;
s.x += Math.cos(s.a) * s.v * k;
s.y += Math.sin(s.a) * s.v * k;
if (s.x < 0) { s.x = 0; s.a = Math.PI - s.a; }
if (s.x > w) { s.x = w; s.a = Math.PI - s.a; }
if (s.y < 0) { s.y = 0; s.a = -s.a; }
if (s.y > h) { s.y = h; s.a = -s.a; }
});
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = gap;
ctx.fillRect(0, 0, w, h);
var all = pointer ? seeds.concat([pointer]) : seeds;
var lw = Math.max(2, Math.min(4, Math.min(w, h) / 110));
var polys = [];
for (var i = 0; i < all.length; i++) {
var p = all[i], poly = [[-2, -2], [w + 2, -2], [w + 2, h + 2], [-2, h + 2]];
for (var j = 0; j < all.length && poly.length; j++) {
if (j === i) continue;
var q = all[j], nx = q.x - p.x, ny = q.y - p.y;
if (!nx && !ny) continue;
poly = clip(poly, nx, ny, (q.x * q.x + q.y * q.y - p.x * p.x - p.y * p.y) / 2);
}
if (poly.length < 3) continue;
poly.seed = p;
polys.push(poly);
}
function trace(poly) {
ctx.beginPath();
ctx.moveTo(poly[0][0], poly[0][1]);
for (var k = 1; k < poly.length; k++) ctx.lineTo(poly[k][0], poly[k][1]);
ctx.closePath();
}
var glow = Math.max(w, h) * 0.18;
polys.forEach(function (poly) {
var p = poly.seed;
trace(poly);
ctx.fillStyle = p.color;
ctx.fill();
// A soft glow round the seed, so each cell looks lit from inside.
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glow);
g.addColorStop(0, 'rgba(255,255,255,.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fill();
});
// The lines between cells, drawn last so they're even all round.
ctx.lineJoin = 'round';
ctx.lineWidth = lw;
ctx.strokeStyle = gap;
polys.forEach(function (poly) { trace(poly); ctx.stroke(); });
if (!showSeeds) return;
all.forEach(function (p) {
ctx.fillStyle = p === pointer ? '#db2777' : 'rgba(26,16,51,.55)';
ctx.beginPath();
ctx.arc(p.x, p.y, p === pointer ? 4 : 2.2, 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();
var x = e.clientX - r.left, y = e.clientY - r.top;
if (!pointer) pointer = { x: x, y: y, color: '#ffffff' };
pointer.x = x;
pointer.y = y;
};
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-voronoi-cells]').forEach(function (el) {
if (el._vrn) return;
el._vrn = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-voronoi-cells style="height: 360px"></div>