Circle Packing: How to Create Beautiful Space-Filling Art With Code
Circle packing is the art of filling a space with circles that don’t overlap. It sounds simple, but the results are extraordinary—organic, fractal-like compositions that feel both mathematical and alive. From ancient coin-stacking problems to modern data visualization, circle packing bridges geometry and aesthetics in ways few other techniques can match.
This guide covers 8 working JavaScript implementations, from a basic greedy packer to an Apollonian gasket, image-based packing, and generative art compositions. Every example runs in a single HTML file under 50KB—paste any of them into your browser console or an HTML file and watch circles fill the void.
What is circle packing?
Circle packing is a class of optimization problems: given a boundary, place as many non-overlapping circles as possible within it. The mathematical study goes back to Apollonius of Perga (c. 200 BCE), who first described how to construct a circle tangent to three given circles. In the 17th century, René Descartes discovered the relationship between the curvatures of four mutually tangent circles, later refined by Frederick Soddy in 1936 into what we now call the Descartes Circle Theorem.
For creative coding, circle packing is compelling because it produces organic-looking results from purely geometric rules. No physics engine, no particle system—just circles finding their place in space. The visual density creates a satisfying sense of completeness, like puzzle pieces fitting together.
1. Basic greedy circle packing
The simplest approach: pick a random point, try to place the largest circle that fits without overlapping existing circles or leaving the boundary. Repeat thousands of times.
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const circles = [];
const maxAttempts = 10000;
const minR = 2, maxR = 80;
for (let i = 0; i < maxAttempts; i++) {
const x = Math.random() * W;
const y = Math.random() * H;
// Find max radius that doesn't overlap
let r = maxR;
// Constrain to canvas bounds
r = Math.min(r, x, y, W - x, H - y);
for (const c of circles) {
const d = Math.hypot(x - c.x, y - c.y) - c.r;
r = Math.min(r, d);
}
if (r >= minR) {
circles.push({ x, y, r });
}
}
// Draw
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
for (const c of circles) {
const hue = (c.r / maxR) * 280 + 180;
ctx.beginPath();
ctx.arc(c.x, c.y, c.r - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = `hsl(${hue}, 70%, 60%)`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
The greedy algorithm is fast and produces pleasing results. Larger circles claim space first (statistically, since they’re more likely to fit early), creating a natural hierarchy from large background circles to tiny gap-fillers. The Math.hypot distance check ensures no overlaps. Constraining radius to canvas edges keeps circles fully visible.
2. Image-based circle packing
Map image brightness to circle size: dark pixels get large circles, light pixels get small ones (or vice versa). This transforms any photograph into a pointillist-style circle composition.
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Generate a synthetic image (gradient with shapes)
const imgCanvas = document.createElement('canvas');
imgCanvas.width = W; imgCanvas.height = H;
const ictx = imgCanvas.getContext('2d');
// Dark background
ictx.fillStyle = '#111';
ictx.fillRect(0, 0, W, H);
// Bright circle
const grd = ictx.createRadialGradient(W/2, H/2, 0, W/2, H/2, 200);
grd.addColorStop(0, '#fff');
grd.addColorStop(1, '#333');
ictx.fillStyle = grd;
ictx.beginPath();
ictx.arc(W/2, H/2, 200, 0, Math.PI * 2);
ictx.fill();
// Star shape
ictx.fillStyle = '#000';
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
const x = W/2 + Math.cos(a) * 80;
const y = H/2 + Math.sin(a) * 80;
ictx.beginPath();
ictx.arc(x, y, 30, 0, Math.PI * 2);
ictx.fill();
}
const imgData = ictx.getImageData(0, 0, W, H).data;
function brightness(x, y) {
const i = (Math.floor(y) * W + Math.floor(x)) * 4;
return (imgData[i] + imgData[i+1] + imgData[i+2]) / 765; // 0-1
}
const circles = [];
for (let attempt = 0; attempt < 15000; attempt++) {
const x = Math.random() * W;
const y = Math.random() * H;
const b = brightness(x, y);
// Bright areas get bigger circles
const maxR = 2 + b * 25;
let r = maxR;
r = Math.min(r, x, y, W - x, H - y);
for (const c of circles) {
r = Math.min(r, Math.hypot(x - c.x, y - c.y) - c.r);
}
if (r >= 1.5) {
circles.push({ x, y, r, b });
}
}
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
for (const c of circles) {
ctx.beginPath();
ctx.arc(c.x, c.y, c.r - 0.3, 0, Math.PI * 2);
ctx.fillStyle = `hsl(40, ${50 + c.b * 40}%, ${20 + c.b * 50}%)`;
ctx.fill();
}
The brightness function samples the synthetic image at each candidate point. Bright regions produce larger circles, creating a density gradient that reveals the original image structure. Replace the synthetic image with drawImage() on a loaded photograph for photo-to-circle-art conversion. The technique is closely related to stippling—both approximate continuous tone through discrete marks.
3. Apollonian gasket
The Apollonian gasket is a fractal produced by recursively filling the gaps between mutually tangent circles. Named after Apollonius of Perga, it’s one of the oldest known fractals, though the term wasn’t coined until the 20th century. The Descartes Circle Theorem gives us the curvature (reciprocal of radius) of the fourth circle tangent to three given mutually tangent circles:
k4 = k1 + k2 + k3 + 2√(k1k2 + k2k3 + k1k3)
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const cx = W / 2, cy = H / 2;
// Three initial tangent circles inside a bounding circle
const R = 250; // bounding radius
const r1 = R * 0.45, r2 = R * 0.35, r3 = R * 0.2;
// Positions: place c1 and c2 tangent, then find c3
// For simplicity, use a symmetric starting config
const circles = [];
function addCircle(x, y, r) {
if (r < 1) return;
// Check overlap with existing
for (const c of circles) {
const d = Math.hypot(x - c.x, y - c.y);
if (d < c.r + r - 1 && Math.abs(d - Math.abs(c.r - r)) > 1) return;
}
circles.push({ x, y, r });
}
// Recursive packing via Descartes theorem (simplified geometric approach)
function packTriple(x1, y1, r1, x2, y2, r2, x3, y3, r3, depth) {
if (depth > 8) return;
// Find circle tangent to all three using Descartes
const k1 = 1/r1, k2 = 1/r2, k3 = 1/r3;
const k4 = k1 + k2 + k3 + 2 * Math.sqrt(k1*k2 + k2*k3 + k1*k3);
const r4 = 1 / k4;
if (r4 < 1.5) return;
// Find position using weighted average (approximation)
const w1 = k1, w2 = k2, w3 = k3;
const wt = w1 + w2 + w3;
let x4 = (w1*x1 + w2*x2 + w3*x3) / wt;
let y4 = (w1*y1 + w2*y2 + w3*y3) / wt;
// Refine position: must be r4+ri away from each circle
for (let iter = 0; iter < 20; iter++) {
let dx = 0, dy = 0;
const pts = [[x1,y1,r1],[x2,y2,r2],[x3,y3,r3]];
for (const [px, py, pr] of pts) {
const d = Math.hypot(x4 - px, y4 - py);
const target = r4 + pr;
if (d > 0.01) {
const factor = (target - d) / d * 0.3;
dx += (x4 - px) * factor;
dy += (y4 - py) * factor;
}
}
x4 += dx; y4 += dy;
}
// Verify tangency
const ok = [[x1,y1,r1],[x2,y2,r2],[x3,y3,r3]].every(([px,py,pr]) => {
const d = Math.hypot(x4-px, y4-py);
return Math.abs(d - (r4+pr)) < r4 * 0.3;
});
if (!ok) return;
addCircle(x4, y4, r4);
// Recurse into three new triples
packTriple(x4, y4, r4, x2, y2, r2, x3, y3, r3, depth+1);
packTriple(x1, y1, r1, x4, y4, r4, x3, y3, r3, depth+1);
packTriple(x1, y1, r1, x2, y2, r2, x4, y4, r4, depth+1);
}
// Initial configuration: 3 mutually tangent circles
const a1 = 0, a2 = 2.1, a3 = 4.2;
const d1 = R - r1, d2 = R - r2, d3 = R - r3;
const c1 = { x: cx + Math.cos(a1)*d1*0.35, y: cy + Math.sin(a1)*d1*0.35, r: r1 };
const c2 = { x: cx + Math.cos(a2)*d2*0.45, y: cy + Math.sin(a2)*d2*0.45, r: r2 };
const c3 = { x: cx + Math.cos(a3)*d3*0.5, y: cy + Math.sin(a3)*d3*0.5, r: r3 };
circles.push(c1, c2, c3);
packTriple(c1.x, c1.y, c1.r, c2.x, c2.y, c2.r, c3.x, c3.y, c3.r, 0);
// Also fill remaining space with greedy packing
for (let i = 0; i < 5000; i++) {
const a = Math.random() * Math.PI * 2;
const d = Math.random() * R;
const x = cx + Math.cos(a) * d;
const y = cy + Math.sin(a) * d;
let r = R - Math.hypot(x - cx, y - cy);
for (const c of circles) {
r = Math.min(r, Math.hypot(x - c.x, y - c.y) - c.r);
}
if (r >= 1.5) circles.push({ x, y, r });
}
// Draw
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
// Bounding circle
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();
for (const c of circles) {
const hue = (Math.hypot(c.x - cx, c.y - cy) / R) * 300;
ctx.beginPath();
ctx.arc(c.x, c.y, c.r - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = `hsl(${hue}, 75%, 55%)`;
ctx.lineWidth = 1.2;
ctx.stroke();
}
The Apollonian gasket has a fractal dimension of approximately 1.3057. Each generation of circles is smaller by a factor related to the Descartes Circle Theorem. The position-finding algorithm uses iterative relaxation to satisfy the tangency constraints—each new circle must touch all three parent circles simultaneously. The greedy fill pass catches gaps the recursive algorithm misses.
4. Organic growth simulation
Instead of placing circles instantly, grow them over time. Each frame, every circle expands until it touches a neighbor. New circles spawn in gaps. The result looks like bacterial colony growth or lichen spreading across rock.
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const circles = [];
const maxCircles = 400;
const growRate = 0.3;
const spawnInterval = 3;
let frame = 0;
function canGrow(c) {
if (c.x - c.r - growRate < 0 || c.x + c.r + growRate > W) return false;
if (c.y - c.r - growRate < 0 || c.y + c.r + growRate > H) return false;
for (const o of circles) {
if (o === c) continue;
const d = Math.hypot(c.x - o.x, c.y - o.y);
if (d < c.r + o.r + growRate + 0.5) return false;
}
return true;
}
function spawn() {
for (let attempt = 0; attempt < 50; attempt++) {
const x = 20 + Math.random() * (W - 40);
const y = 20 + Math.random() * (H - 40);
let ok = true;
for (const c of circles) {
if (Math.hypot(x - c.x, y - c.y) < c.r + 4) { ok = false; break; }
}
if (ok) {
circles.push({
x, y, r: 2, growing: true,
hue: Math.random() * 360,
birth: frame
});
return;
}
}
}
// Seed
for (let i = 0; i < 5; i++) spawn();
function render() {
frame++;
// Spawn new circles
if (frame % spawnInterval === 0 && circles.length < maxCircles) {
spawn();
}
// Grow
for (const c of circles) {
if (c.growing) {
if (canGrow(c)) {
c.r += growRate;
} else {
c.growing = false;
}
}
}
// Draw
ctx.fillStyle = 'rgba(10, 10, 10, 0.15)';
ctx.fillRect(0, 0, W, H);
for (const c of circles) {
const age = (frame - c.birth) / 200;
const light = c.growing ? 55 : 40;
const sat = c.growing ? 80 : 50;
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${c.hue}, ${sat}%, ${light}%, 0.8)`;
ctx.fill();
ctx.strokeStyle = `hsla(${c.hue}, ${sat}%, ${light + 20}%, 0.6)`;
ctx.lineWidth = 0.8;
ctx.stroke();
}
requestAnimationFrame(render);
}
render();
The growth model is a form of competitive space allocation. Each circle competes for territory by growing until blocked. Early arrivals claim more space (a first-mover advantage), producing the characteristic size hierarchy. The semi-transparent background creates a subtle trail effect that shows growth history. Try adjusting growRate and spawnInterval to shift between rapid chaotic packing and slow deliberate growth.
5. Text-filled circle packing
Render text into a hidden canvas, sample its pixels, and pack circles only where the text exists. Each circle’s size depends on its position within the letter’s stroke.
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Render text to sample
const txtCanvas = document.createElement('canvas');
txtCanvas.width = W; txtCanvas.height = H;
const tctx = txtCanvas.getContext('2d');
tctx.fillStyle = '#000';
tctx.fillRect(0, 0, W, H);
tctx.fillStyle = '#fff';
tctx.font = 'bold 220px Arial, sans-serif';
tctx.textAlign = 'center';
tctx.textBaseline = 'middle';
tctx.fillText('ART', W/2, H/2);
const txtData = tctx.getImageData(0, 0, W, H).data;
function isText(x, y) {
const i = (Math.floor(y) * W + Math.floor(x)) * 4;
return txtData[i] > 128;
}
const circles = [];
for (let attempt = 0; attempt < 20000; attempt++) {
const x = Math.random() * W;
const y = Math.random() * H;
if (!isText(x, y)) continue;
let r = 30;
r = Math.min(r, x, y, W - x, H - y);
// Constrain to text boundary (sample in 8 directions)
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
for (let d = 1; d < r; d += 1) {
const sx = x + Math.cos(a) * d;
const sy = y + Math.sin(a) * d;
if (!isText(sx, sy)) {
r = Math.min(r, d - 1);
break;
}
}
}
for (const c of circles) {
r = Math.min(r, Math.hypot(x - c.x, y - c.y) - c.r);
}
if (r >= 1.5) {
circles.push({ x, y, r });
}
}
// Draw
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
for (const c of circles) {
const hue = (c.x / W) * 60 + 200; // blue to purple gradient
ctx.beginPath();
ctx.arc(c.x, c.y, c.r - 0.3, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 70%, 55%, 0.85)`;
ctx.fill();
ctx.strokeStyle = `hsla(${hue}, 80%, 70%, 0.5)`;
ctx.lineWidth = 0.6;
ctx.stroke();
}
// Subtle text outline
ctx.globalAlpha = 0.1;
ctx.font = 'bold 220px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeText('ART', W/2, H/2);
ctx.globalAlpha = 1;
The 8-direction boundary sampling ensures circles don’t extend beyond the letter edges. Thin strokes (like the crossbar of “A”) get smaller circles, while wide areas (the bowl of “R”) get larger ones, creating a natural density variation that reveals the typographic structure. This technique is widely used in poster design and data visualization for text-shaped infographics.
6. Interactive circle packing
Click or move the mouse to spawn circles that grow outward from the cursor. A repulsion zone around the cursor pushes circles away, creating a dynamic, user-controlled composition.
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let mx = W/2, my = H/2;
canvas.addEventListener('mousemove', e => {
const r = canvas.getBoundingClientRect();
mx = e.clientX - r.left;
my = e.clientY - r.top;
});
const circles = [];
const repelR = 60;
function spawn(x, y) {
for (let i = 0; i < 3; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = 10 + Math.random() * 30;
const sx = x + Math.cos(angle) * dist;
const sy = y + Math.sin(angle) * dist;
if (sx < 5 || sx > W-5 || sy < 5 || sy > H-5) continue;
let ok = true;
for (const c of circles) {
if (Math.hypot(sx - c.x, sy - c.y) < c.r + 3) { ok = false; break; }
}
if (ok) {
circles.push({
x: sx, y: sy, r: 2, growing: true,
vx: 0, vy: 0,
hue: (Date.now() * 0.05 + Math.random() * 60) % 360
});
}
}
}
let lastSpawn = 0;
function render() {
const now = Date.now();
if (now - lastSpawn > 100) {
spawn(mx, my);
lastSpawn = now;
}
// Grow circles
for (const c of circles) {
if (!c.growing) continue;
let canGrow = true;
if (c.x - c.r < 1 || c.x + c.r > W-1 || c.y - c.r < 1 || c.y + c.r > H-1) canGrow = false;
for (const o of circles) {
if (o === c) continue;
if (Math.hypot(c.x - o.x, c.y - o.y) < c.r + o.r + 1) { canGrow = false; break; }
}
if (canGrow) c.r += 0.4;
else c.growing = false;
}
// Repel from mouse
for (const c of circles) {
const d = Math.hypot(c.x - mx, c.y - my);
if (d < repelR + c.r && d > 0) {
const force = (repelR + c.r - d) / (repelR + c.r) * 2;
c.vx += ((c.x - mx) / d) * force;
c.vy += ((c.y - my) / d) * force;
}
c.x += c.vx;
c.y += c.vy;
c.vx *= 0.9;
c.vy *= 0.9;
c.x = Math.max(c.r, Math.min(W - c.r, c.x));
c.y = Math.max(c.r, Math.min(H - c.r, c.y));
}
// Limit total circles
if (circles.length > 500) circles.splice(0, circles.length - 500);
ctx.fillStyle = 'rgba(10, 10, 10, 0.1)';
ctx.fillRect(0, 0, W, H);
for (const c of circles) {
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${c.hue}, 75%, 55%, 0.7)`;
ctx.fill();
}
// Cursor glow
ctx.beginPath();
ctx.arc(mx, my, repelR, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
ctx.stroke();
requestAnimationFrame(render);
}
render();
Mouse repulsion adds a physics-like quality: circles scatter as the cursor approaches and settle back once it moves away. The time-based hue cycling means circles spawned at different moments have different colors, creating a chronological color gradient across the composition. The splice limit prevents unbounded memory growth while keeping the most recent circles.
7. Circle packing tree visualization
Hierarchical data can be represented as nested circles: each parent circle contains its children. This is the basis of D3.js’s d3.pack() layout, one of the most widely used circle packing applications in data visualization.
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Hierarchical data
const tree = {
name: 'Art', children: [
{ name: 'Visual', children: [
{ name: 'Paint', value: 40 },
{ name: 'Photo', value: 30 },
{ name: 'Sculpt', value: 25 },
{ name: 'Print', value: 15 },
]},
{ name: 'Digital', children: [
{ name: 'Code', value: 35 },
{ name: 'Shader', value: 25 },
{ name: 'AI', value: 30 },
{ name: 'Pixel', value: 20 },
]},
{ name: 'Music', children: [
{ name: 'Synth', value: 30 },
{ name: 'Algo', value: 20 },
{ name: 'Live', value: 15 },
]},
{ name: 'Motion', children: [
{ name: 'Anim', value: 25 },
{ name: 'VJ', value: 15 },
]},
]
};
// Simple circle packing layout
function packChildren(node, cx, cy, r) {
node.x = cx; node.y = cy; node.r = r;
if (!node.children) return;
const totalValue = node.children.reduce((s, c) => s + (c.value || sumValues(c)), 0);
const padding = r * 0.08;
const innerR = r - padding;
// Place children using a spiral
let angle = 0;
const placed = [];
for (const child of node.children) {
const childValue = child.value || sumValues(child);
const childR = innerR * Math.sqrt(childValue / totalValue) * 0.85;
// Find position using spiral search
let bestX = cx, bestY = cy, bestD = Infinity;
for (let tries = 0; tries < 200; tries++) {
const dist = (tries / 200) * (innerR - childR);
const a = tries * 0.5;
const tx = cx + Math.cos(a) * dist;
const ty = cy + Math.sin(a) * dist;
// Check bounds
if (Math.hypot(tx - cx, ty - cy) + childR > innerR) continue;
// Check overlaps
let overlap = false;
for (const p of placed) {
if (Math.hypot(tx - p.x, ty - p.y) < childR + p.r + 2) {
overlap = true; break;
}
}
if (!overlap) {
bestX = tx; bestY = ty;
break;
}
}
placed.push({ x: bestX, y: bestY, r: childR });
packChildren(child, bestX, bestY, childR);
}
}
function sumValues(node) {
if (node.value) return node.value;
return (node.children || []).reduce((s, c) => s + sumValues(c), 0);
}
packChildren(tree, W/2, H/2, 270);
// Draw
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
const colors = ['#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'];
function drawNode(node, depth) {
ctx.beginPath();
ctx.arc(node.x, node.y, node.r, 0, Math.PI * 2);
const color = colors[depth % colors.length];
ctx.fillStyle = color + '30';
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = depth === 0 ? 2 : 1;
ctx.stroke();
// Label
if (node.name) {
const fontSize = Math.max(8, node.r * 0.35);
ctx.font = `${fontSize}px sans-serif`;
ctx.fillStyle = '#eee';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (!node.children || node.r < 30) {
ctx.fillText(node.name, node.x, node.y);
} else {
ctx.fillText(node.name, node.x, node.y - node.r + fontSize + 4);
}
}
if (node.children) {
for (const child of node.children) drawNode(child, depth + 1);
}
}
drawNode(tree, 0);
Nested circle packing encodes two dimensions of information: size represents quantity, nesting represents hierarchy. The spiral placement heuristic finds non-overlapping positions without the complexity of a full physics simulation. This is the same core algorithm behind D3’s circle packing layouts, used by the New York Times, Observable, and countless data journalism projects. The key insight: area is proportional to value, so radius = sqrt(value/total) * parentRadius.
8. Generative circle packing art
Combine all techniques into a single animated composition: layered circle packing with varying styles, animated growth, color palettes, and depth effects.
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Palette
const palettes = [
['#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff'],
['#00b894','#00cec9','#0984e3','#6c5ce7','#fd79a8'],
['#e17055','#fdcb6e','#55efc4','#74b9ff','#a29bfe'],
];
const pal = palettes[Math.floor(Math.random() * palettes.length)];
const layers = [];
// Layer 1: large background circles (stroked)
const bg = [];
for (let i = 0; i < 3000; i++) {
const x = Math.random() * W;
const y = Math.random() * H;
let r = 60;
r = Math.min(r, x, y, W - x, H - y);
for (const c of bg) r = Math.min(r, Math.hypot(x - c.x, y - c.y) - c.r);
if (r >= 4) bg.push({ x, y, r, phase: Math.random() * Math.PI * 2 });
}
layers.push(bg);
// Layer 2: medium circles (filled, semi-transparent)
const mid = [];
for (let i = 0; i < 5000; i++) {
const x = Math.random() * W;
const y = Math.random() * H;
let r = 25;
r = Math.min(r, x, y, W - x, H - y);
for (const c of mid) r = Math.min(r, Math.hypot(x - c.x, y - c.y) - c.r);
if (r >= 2) mid.push({ x, y, r, phase: Math.random() * Math.PI * 2 });
}
layers.push(mid);
let t = 0;
function render() {
t += 0.01;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
// Background layer: breathing stroked circles
for (const c of layers[0]) {
const breathe = 1 + Math.sin(t * 2 + c.phase) * 0.05;
const r = c.r * breathe;
ctx.beginPath();
ctx.arc(c.x, c.y, r - 0.5, 0, Math.PI * 2);
const ci = Math.floor((c.x + c.y) / 200) % pal.length;
ctx.strokeStyle = pal[ci] + '60';
ctx.lineWidth = 1;
ctx.stroke();
}
// Middle layer: filled circles with pulse
for (const c of layers[1]) {
const pulse = 1 + Math.sin(t * 3 + c.phase) * 0.08;
const r = c.r * pulse;
ctx.beginPath();
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
const ci = Math.floor((c.x * 0.01 + t) % pal.length);
ctx.fillStyle = pal[Math.abs(ci) % pal.length] + '40';
ctx.fill();
}
// Highlight ring: animated concentric ring that reveals circles
const ringR = ((t * 40) % (W * 0.8)) + 20;
const ringW = 30;
for (const layer of layers) {
for (const c of layer) {
const d = Math.hypot(c.x - W/2, c.y - H/2);
if (Math.abs(d - ringR) < ringW) {
const intensity = 1 - Math.abs(d - ringR) / ringW;
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255, 255, 255, ${intensity * 0.5})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
// Central glow
const grd = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, 200);
grd.addColorStop(0, 'rgba(255,255,255,0.03)');
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, W, H);
requestAnimationFrame(render);
}
render();
The generative composition layers two independently packed sets of circles with different size ranges and rendering styles (stroked vs filled). The breathing animation (sin(t + phase)) gives each circle its own rhythm. The expanding ring highlight creates a radar-sweep effect that draws attention across the composition. Color assignment uses spatial position plus time offset for a slowly shifting palette. The combination of static packing structure and dynamic animation creates a piece that feels both orderly and alive.
The mathematics of optimal packing
How efficiently can circles fill a plane? The answer has been known since 1773, when Joseph-Louis Lagrange proved that the hexagonal lattice achieves the highest packing density: π/(2√3) ≈ 0.9069, or about 90.69% of the area. This means even the best arrangement of identical circles wastes about 9.3% of space.
For circles of varying sizes, the problem is NP-hard—no polynomial-time algorithm guarantees an optimal solution. This is why our greedy algorithms work well in practice: they don’t need optimality, just visual density. A greedy random packer typically achieves 60–75% coverage, which looks satisfyingly full to the human eye.
The Apollonian gasket is special because it achieves 100% coverage in the limit (infinite circles). Its fractal dimension (~1.3057) means it’s more than a line but less than a surface—a one-dimensional object with two-dimensional ambitions.
Circle packing in nature and science
Circle packing appears throughout the natural world. Soap bubbles on a flat surface form a circle packing (before pressure equalizes them into hexagons). Cross-sections of bamboo stems show nested circle arrangements. The compound eyes of insects—thousands of hexagonally packed circular lenses—are nature’s most visible example.
In materials science, circle packing models grain structures in polycrystalline metals. In wireless networks, circle packing determines optimal antenna placement for coverage without interference. In medicine, packing algorithms help plan radiation therapy beam arrangements.
The Koebe–Andreev–Thurston theorem (1936–1985) proves that every planar graph can be represented as a circle packing—a profound connection between topology and geometry. This theorem is the foundation for conformal mapping algorithms used in computer graphics, brain imaging, and mesh generation.
Performance tips
- Use a spatial index. For thousands of circles, checking every pair is O(n²). A grid-based spatial hash reduces overlap checks to O(1) average. Divide the canvas into cells of size 2×maxRadius, and only check circles in the same or adjacent cells.
- Sort by size. Place larger circles first. They constrain the space more, and it’s easier to fit small circles in remaining gaps than vice versa.
- Pre-compute distance fields. For image-based packing, compute a signed distance field from the boundary once, then look up max radius at any point in O(1) instead of ray-marching 8 directions.
- Limit attempts, not circles. Instead of targeting a circle count, set a maximum number of placement attempts. When attempts consistently fail, the space is full.
- Offscreen canvas for sampling. When packing into text or image shapes, render the mask to an offscreen canvas once. Sample the
ImageDataarray directly—it’s orders of magnitude faster than callinggetImageDataper pixel.
Explore more space-filling art on Lumitree, where every branch is a unique micro-world built from code. For related topics, see the Voronoi diagram guide, the math art tutorial, or the procedural generation deep-dive.