Circle Packing
Circles pop up in empty spots and grow until they touch a neighbour or the edge, then stop. New ones squeeze into the gaps between them, smaller and smaller, until the box is full. Loop it and the circles shrink away in a ripple from the middle, then it fills again.
How to use it
- Paste the snippet just before
</head>. - Add
data-circle-packingto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated circle colours. Default violets, pinks, amber and sky. |
data-max | Largest radius in px. Default a fifth of the box’s shorter side. |
data-min | Smallest radius in px: spots with less room are skipped. Default 2. |
data-gap | Space between circles in px. Default 2. |
data-style | fill or ring (outlines only). Default fill. |
data-speed | Speed multiplier. Default 1. |
data-loop | Hold the full box, shrink the circles away and fill it again. |
It stops once it has tried a long run of random spots without finding room for even the smallest circle. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows the box already full.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Circle Packing: circles pop up in empty spots and grow until they touch
// a neighbour or the edge, then stop. New ones squeeze into the gaps
// between them, smaller and smaller, until the box is full. With
// data-loop it holds, the circles shrink away in a ripple from the middle
// and it fills again. Drawn on a transparent canvas and paused when off
// screen.
// Usage: <div data-circle-packing style="height:360px"></div>
// Optional: data-colors (comma-separated circle colours, default violets,
// pinks, amber and sky), data-max (largest radius in px, default a fifth
// of the box's shorter side), data-min (smallest radius in px; spots with
// less room are skipped, default 2), data-gap (space between circles in
// px, default 2), data-speed (speed multiplier, default 1), data-style
// ("fill" or "ring", default fill), data-loop (shrink away and fill
// again).
var DEFAULTS = { colors: '#7c3aed,#a855f7,#c084fc,#ec4899,#f472b6,#f59e0b,#38bdf8', min: 2, gap: 2, speed: 1, hold: 2200 };
var css =
'[data-circle-packing]{position:relative;isolation:isolate;overflow:hidden}' +
'.cpk-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 = 'cpk-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 minR = +d.min || DEFAULTS.min;
var gap = d.gap !== undefined ? +d.gap || 0 : DEFAULTS.gap;
var speed = +d.speed || DEFAULTS.speed;
var ring = d.style === 'ring';
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, maxR = 40, circles = [], misses = 0, phase = 'grow', age = 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);
maxR = +d.max || Math.max(12, Math.min(w, h) / 5);
return true;
}
function start() {
circles = [];
misses = 0;
phase = 'grow';
age = 0;
}
// How much room there is at (x, y): distance to the nearest circle or edge.
function room(x, y) {
var best = Math.min(x, y, w - x, h - y);
for (var i = 0; i < circles.length && best > 0; i++) {
var c = circles[i], dx = x - c.x, dy = y - c.y;
var dd = Math.sqrt(dx * dx + dy * dy) - c.r - gap;
if (dd < best) best = dd;
}
return best;
}
// Try a few random spots; start a circle in each that has room.
function spawn(tries, most) {
var made = 0;
for (var t = 0; t < tries && made < most; t++) {
var x = Math.random() * w, y = Math.random() * h;
if (room(x, y) >= minR) {
circles.push({ x: x, y: y, r: 0.5, grow: true, color: colors[Math.floor(Math.random() * colors.length)], s: 1 });
made++;
misses = 0;
} else misses++;
}
}
function grow(dt) {
var step = dt / 1000 * speed * Math.max(20, Math.min(w, h) * 0.15);
circles.forEach(function (c) {
if (!c.grow) return;
c.r += step;
var stop = c.r >= maxR || c.x - c.r <= 0 || c.y - c.r <= 0 || c.x + c.r >= w || c.y + c.r >= h;
for (var i = 0; i < circles.length && !stop; i++) {
var o = circles[i];
if (o === c) continue;
var dx = c.x - o.x, dy = c.y - o.y, need = c.r + o.r + gap;
if (dx * dx + dy * dy <= need * need) stop = true;
}
if (stop) {
c.grow = false;
// Back off to just touching.
var fit = Math.min(maxR, c.x, c.y, w - c.x, h - c.y);
circles.forEach(function (o) {
if (o === c) return;
var dd = Math.sqrt((c.x - o.x) * (c.x - o.x) + (c.y - o.y) * (c.y - o.y)) - o.r - gap;
if (dd < fit) fit = dd;
});
c.r = Math.max(0.5, fit);
}
});
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
circles.forEach(function (c) {
var r = c.r * c.s;
if (r < 0.3) return;
ctx.beginPath();
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
if (ring) {
ctx.strokeStyle = c.color;
ctx.lineWidth = Math.max(1, Math.min(2.5, r * 0.18));
ctx.globalAlpha = c.grow ? 1 : 0.85;
ctx.stroke();
} else {
ctx.fillStyle = c.color;
ctx.globalAlpha = c.grow ? 1 : 0.88;
ctx.fill();
if (r > 6) {
// A soft highlight, so they read as little spheres.
var g = ctx.createRadialGradient(c.x - r * 0.35, c.y - r * 0.4, 0, c.x - r * 0.35, c.y - r * 0.4, r * 1.1);
g.addColorStop(0, 'rgba(255,255,255,.4)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fill();
}
}
});
ctx.globalAlpha = 1;
}
function tick(dt) {
age += dt;
if (phase === 'grow') {
// Big ones first, a few at a time; more tries as the gaps get smaller.
var full = circles.length;
spawn(full < 40 ? 8 : 40, full < 40 ? 1 : Math.min(6, 1 + Math.floor(full / 120)));
grow(dt);
var busy = circles.some(function (c) { return c.grow; });
if (misses > 1500 && !busy) { phase = 'hold'; age = 0; }
} else if (phase === 'hold' && loop && age > DEFAULTS.hold) {
phase = 'shrink';
age = 0;
// Shrink away in a ripple from the middle.
var far = Math.sqrt(w * w + h * h) / 2;
anime.animate(circles, {
s: 0,
duration: 450,
ease: 'in(2)',
delay: function (c) { return Math.sqrt((c.x - w / 2) * (c.x - w / 2) + (c.y - h / 2) * (c.y - h / 2)) / far * 900; },
onComplete: start
});
}
}
function still() {
start();
for (var i = 0; i < 4000 && phase === 'grow'; i++) tick(16);
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-circle-packing]').forEach(function (el) {
if (el._cpk) return;
el._cpk = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-circle-packing data-loop style="height: 360px; background: #120a24"></div>
<!-- outlines: -->
<div data-circle-packing data-style="ring" style="height: 360px; background: #120a24"></div>