All articles
24 min read

Voronoi Diagram: How to Create Beautiful Organic Patterns With Code

voronoi diagramdelaunay triangulationcreative codingJavaScriptgenerative artcomputational geometryalgorithmic art

Take any set of points scattered across a plane. Now, for every location on that plane, ask: which point is closest? Color each region by its nearest point, and you get a Voronoi diagram — one of the most beautiful and useful structures in computational geometry. The pattern looks organic, almost biological: irregular cells packed together like soap bubbles, like cracked mud, like the scales on a giraffe's neck or the cells under a microscope.

Georgy Voronoi formalized this partition in 1908, but the pattern was observed centuries earlier. Descartes sketched something similar when mapping the solar system in 1644. John Snow used a Voronoi-like analysis in 1854 to trace London's cholera outbreak to a contaminated water pump. Today, Voronoi diagrams appear in biology (cell territories), materials science (crystal grain boundaries), urban planning (nearest facility maps), game development (procedural terrain), and art.

In this guide, we'll build 8 Voronoi visualizations from scratch using JavaScript and the HTML Canvas API. No libraries. No frameworks. Just points, distances, and pixels.

1. Basic Voronoi — brute-force pixel coloring

The simplest way to draw a Voronoi diagram: for every pixel, find the closest seed point and color it accordingly. This brute-force approach is O(n × width × height) — not fast for large point counts, but perfect for understanding the concept and creating beautiful visuals with dozens of seeds.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Generate random seed points
const seeds = [];
const N = 30;
for (let i = 0; i < N; i++) {
  seeds.push({
    x: Math.random() * 800,
    y: Math.random() * 600,
    hue: (i / N) * 360
  });
}

// For each pixel, find nearest seed
const img = ctx.createImageData(800, 600);
for (let py = 0; py < 600; py++) {
  for (let px = 0; px < 800; px++) {
    let minDist = Infinity, closest = 0;
    for (let i = 0; i < N; i++) {
      const dx = px - seeds[i].x, dy = py - seeds[i].y;
      const d = dx * dx + dy * dy; // squared distance (skip sqrt)
      if (d < minDist) { minDist = d; closest = i; }
    }
    const idx = (py * 800 + px) * 4;
    // HSL to RGB conversion inline
    const h = seeds[closest].hue / 60;
    const s = 0.6, l = 0.5;
    const c = (1 - Math.abs(2 * l - 1)) * s;
    const x = c * (1 - Math.abs(h % 2 - 1));
    const m = l - c / 2;
    let r = m, g = m, b = m;
    if (h < 1) { r += c; g += x; }
    else if (h < 2) { r += x; g += c; }
    else if (h < 3) { g += c; b += x; }
    else if (h < 4) { g += x; b += c; }
    else if (h < 5) { r += x; b += c; }
    else { r += c; b += x; }
    img.data[idx] = r * 255;
    img.data[idx+1] = g * 255;
    img.data[idx+2] = b * 255;
    img.data[idx+3] = 255;
  }
}
ctx.putImageData(img, 0, 0);

// Draw cell edges (where nearest and second-nearest are close)
const edgeImg = ctx.getImageData(0, 0, 800, 600);
for (let py = 1; py < 599; py++) {
  for (let px = 1; px < 799; px++) {
    const idx = (py * 800 + px) * 4;
    const idxR = (py * 800 + px + 1) * 4;
    const idxD = ((py + 1) * 800 + px) * 4;
    // If neighboring pixels have different colors, draw edge
    if (Math.abs(edgeImg.data[idx] - edgeImg.data[idxR]) > 2 ||
        Math.abs(edgeImg.data[idx] - edgeImg.data[idxD]) > 2) {
      img.data[idx] = img.data[idx+1] = img.data[idx+2] = 30;
    }
  }
}
ctx.putImageData(img, 0, 0);

// Draw seed points
seeds.forEach(s => {
  ctx.beginPath();
  ctx.arc(s.x, s.y, 3, 0, Math.PI * 2);
  ctx.fillStyle = '#fff';
  ctx.fill();
});

The key insight: we never calculate cell boundaries explicitly. We simply ask "who is closest?" for each pixel, and the boundaries emerge automatically. This is the defining property of Voronoi diagrams — they are dual to proximity.

2. Distance metrics — Manhattan, Chebyshev, and Minkowski

Euclidean distance produces the organic, curved cells we expect. But swap in a different distance metric and the geometry transforms completely. Manhattan distance (L1 norm) creates diamond-shaped cells. Chebyshev distance (L-infinity) produces square cells. The Minkowski distance with a fractional exponent creates star-shaped, crystalline patterns.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const seeds = [];
for (let i = 0; i < 25; i++) {
  seeds.push({
    x: Math.random() * 800,
    y: Math.random() * 600,
    hue: (i / 25) * 360
  });
}

// Distance functions
const metrics = {
  euclidean: (dx, dy) => Math.sqrt(dx * dx + dy * dy),
  manhattan: (dx, dy) => Math.abs(dx) + Math.abs(dy),
  chebyshev: (dx, dy) => Math.max(Math.abs(dx), Math.abs(dy)),
  minkowski: (dx, dy, p = 0.5) =>
    Math.pow(Math.pow(Math.abs(dx), p) + Math.pow(Math.abs(dy), p), 1 / p)
};

let currentMetric = 'euclidean';
let time = 0;

function draw() {
  const dist = metrics[currentMetric];
  const img = ctx.createImageData(800, 600);

  for (let py = 0; py < 600; py++) {
    for (let px = 0; px < 800; px++) {
      let minD = Infinity, closest = 0;
      for (let i = 0; i < seeds.length; i++) {
        const d = dist(px - seeds[i].x, py - seeds[i].y);
        if (d < minD) { minD = d; closest = i; }
      }
      const idx = (py * 800 + px) * 4;
      const h = seeds[closest].hue;
      // Shade by distance to seed for 3D effect
      const shade = Math.max(0.3, 1 - minD / 120);
      const hi = Math.floor(h / 60) % 6;
      const f = h / 60 - Math.floor(h / 60);
      const v = shade, s = 0.7;
      const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
      const rgb = [[v,t,p],[q,v,p],[p,v,t],[p,q,v],[t,p,v],[v,p,q]][hi];
      img.data[idx] = rgb[0] * 255;
      img.data[idx+1] = rgb[1] * 255;
      img.data[idx+2] = rgb[2] * 255;
      img.data[idx+3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);

  // Label
  ctx.fillStyle = '#fff';
  ctx.font = '16px monospace';
  ctx.fillText('Distance: ' + currentMetric + ' (click to cycle)', 20, 30);
}

// Cycle through metrics on click
const names = Object.keys(metrics);
let mi = 0;
canvas.onclick = () => {
  mi = (mi + 1) % names.length;
  currentMetric = names[mi];
  draw();
};

draw();

Manhattan distance creates cells that look like city blocks — fitting, since it measures "taxi cab" distance. Chebyshev distance (the "king's move" in chess) produces square regions. The Minkowski metric with p < 1 creates concave, star-shaped cells that look like crystalline formations. Each metric reveals a different geometry hiding in the same set of points.

3. Weighted Voronoi — power diagrams

In a standard Voronoi diagram, all seeds are equal. In a weighted (or "power") Voronoi diagram, each seed has a weight that expands or shrinks its territory. Seeds with higher weights claim more space. This models scenarios where influence isn't uniform: larger cities have bigger service areas, brighter stars outshine dimmer neighbors, dominant cells crowd out weaker ones.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const seeds = [];
for (let i = 0; i < 20; i++) {
  seeds.push({
    x: 60 + Math.random() * 680,
    y: 60 + Math.random() * 480,
    weight: 0.5 + Math.random() * 2, // weight between 0.5 and 2.5
    hue: (i / 20) * 360
  });
}

function draw() {
  const img = ctx.createImageData(800, 600);
  for (let py = 0; py < 600; py++) {
    for (let px = 0; px < 800; px++) {
      let minD = Infinity, closest = 0;
      for (let i = 0; i < seeds.length; i++) {
        const dx = px - seeds[i].x, dy = py - seeds[i].y;
        // Weighted distance: divide by weight (larger weight = closer)
        const d = (dx * dx + dy * dy) / (seeds[i].weight * seeds[i].weight);
        if (d < minD) { minD = d; closest = i; }
      }
      const idx = (py * 800 + px) * 4;
      const h = seeds[closest].hue / 60;
      const c = 0.5, x2 = c * (1 - Math.abs(h % 2 - 1)), m = 0.25;
      let r = m, g = m, b = m;
      if (h < 1) { r += c; g += x2; }
      else if (h < 2) { r += x2; g += c; }
      else if (h < 3) { g += c; b += x2; }
      else if (h < 4) { g += x2; b += c; }
      else if (h < 5) { r += x2; b += c; }
      else { r += c; b += x2; }
      img.data[idx] = r * 255;
      img.data[idx+1] = g * 255;
      img.data[idx+2] = b * 255;
      img.data[idx+3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);

  // Draw seeds with size proportional to weight
  seeds.forEach(s => {
    ctx.beginPath();
    ctx.arc(s.x, s.y, s.weight * 6, 0, Math.PI * 2);
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.beginPath();
    ctx.arc(s.x, s.y, 2, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
  });
}

// Click to add new seed with random weight
canvas.onclick = (e) => {
  const rect = canvas.getBoundingClientRect();
  seeds.push({
    x: e.clientX - rect.left,
    y: e.clientY - rect.top,
    weight: 0.5 + Math.random() * 2.5,
    hue: Math.random() * 360
  });
  draw();
};

draw();

Weighted Voronoi diagrams are used in cartography (population-weighted service areas), ecology (territory modeling where larger animals dominate more space), and competitive analysis (market share where brand strength varies). In generative art, they create organic hierarchies — some cells dominate while others are squeezed into narrow slivers.

4. Lloyd relaxation — from chaos to order

Start with random seed points and a Voronoi diagram looks chaotic — cells vary wildly in size and shape. Apply Lloyd's relaxation algorithm and watch it transform: repeatedly move each seed to the centroid (center of mass) of its cell, then recompute the diagram. After 20-30 iterations, the cells converge to a remarkably uniform, hexagonal-ish tiling called a centroidal Voronoi tessellation. The process is hypnotic to watch.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 40;
const seeds = [];
for (let i = 0; i < N; i++) {
  seeds.push({
    x: Math.random() * 800,
    y: Math.random() * 600,
    hue: (i / N) * 360
  });
}

function computeAndDraw() {
  // Assign each pixel to nearest seed and accumulate centroids
  const cx = new Float64Array(N), cy = new Float64Array(N);
  const count = new Uint32Array(N);
  const cellColor = new Uint8Array(800 * 600);

  for (let py = 0; py < 600; py++) {
    for (let px = 0; px < 800; px++) {
      let minD = Infinity, closest = 0;
      for (let i = 0; i < N; i++) {
        const dx = px - seeds[i].x, dy = py - seeds[i].y;
        const d = dx * dx + dy * dy;
        if (d < minD) { minD = d; closest = i; }
      }
      cellColor[py * 800 + px] = closest;
      cx[closest] += px;
      cy[closest] += py;
      count[closest]++;
    }
  }

  // Draw cells
  const img = ctx.createImageData(800, 600);
  for (let py = 0; py < 600; py++) {
    for (let px = 0; px < 800; px++) {
      const c = cellColor[py * 800 + px];
      const idx = (py * 800 + px) * 4;
      const h = seeds[c].hue / 60;
      const ch = 0.5, x2 = ch * (1 - Math.abs(h % 2 - 1)), m = 0.2;
      let r = m, g = m, b = m;
      if (h < 1) { r += ch; g += x2; }
      else if (h < 2) { r += x2; g += ch; }
      else if (h < 3) { g += ch; b += x2; }
      else if (h < 4) { g += x2; b += ch; }
      else if (h < 5) { r += x2; b += ch; }
      else { r += ch; b += x2; }
      img.data[idx] = r * 255;
      img.data[idx+1] = g * 255;
      img.data[idx+2] = b * 255;
      img.data[idx+3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);

  // Draw seeds
  seeds.forEach(s => {
    ctx.beginPath();
    ctx.arc(s.x, s.y, 3, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
  });

  // Move seeds toward centroids (Lloyd relaxation step)
  for (let i = 0; i < N; i++) {
    if (count[i] > 0) {
      seeds[i].x += (cx[i] / count[i] - seeds[i].x) * 0.3;
      seeds[i].y += (cy[i] / count[i] - seeds[i].y) * 0.3;
    }
  }
}

let iteration = 0;
function animate() {
  computeAndDraw();
  iteration++;
  ctx.fillStyle = '#fff';
  ctx.font = '14px monospace';
  ctx.fillText('Iteration: ' + iteration, 20, 25);
  if (iteration < 60) requestAnimationFrame(animate);
}
animate();

Lloyd relaxation is used in mesh generation (creating well-shaped triangles for finite element analysis), stippling (recreating photographs with dots — see Robert Bridson's work), k-means clustering initialization, and blue noise sampling. The convergence from disorder to near-perfect hexagonal packing is a visual demonstration of nature's tendency toward minimum-energy configurations.

5. Delaunay triangulation — the dual graph

Every Voronoi diagram has a dual: the Delaunay triangulation. Connect two seed points if their Voronoi cells share an edge, and you get a triangulation with a remarkable property — no point lies inside the circumcircle of any triangle. This "maximize the minimum angle" property makes Delaunay triangulations ideal for mesh generation, terrain modeling, and interpolation.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Generate points
const points = [];
for (let i = 0; i < 50; i++) {
  points.push([40 + Math.random() * 720, 40 + Math.random() * 520]);
}

// Bowyer-Watson incremental Delaunay triangulation
function delaunay(pts) {
  // Super-triangle enclosing all points
  const st = [[-1000, -1000], [2600, -1000], [800, 2600]];
  let triangles = [[0, 1, 2]]; // indices into allPts
  const allPts = [...st, ...pts];

  for (let i = 3; i < allPts.length; i++) {
    const p = allPts[i];
    const bad = [], edges = [];

    // Find triangles whose circumcircle contains p
    for (let j = triangles.length - 1; j >= 0; j--) {
      const t = triangles[j];
      const [ax, ay] = allPts[t[0]], [bx, by] = allPts[t[1]], [cx, cy] = allPts[t[2]];
      const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
      if (Math.abs(D) < 1e-10) continue;
      const ux = ((ax*ax + ay*ay) * (by-cy) + (bx*bx+by*by) * (cy-ay) + (cx*cx+cy*cy) * (ay-by)) / D;
      const uy = ((ax*ax + ay*ay) * (cx-bx) + (bx*bx+by*by) * (ax-cx) + (cx*cx+cy*cy) * (bx-ax)) / D;
      const r2 = (ax-ux)*(ax-ux) + (ay-uy)*(ay-uy);
      if ((p[0]-ux)*(p[0]-ux) + (p[1]-uy)*(p[1]-uy) < r2) bad.push(j);
    }

    // Collect boundary edges of the cavity
    for (const j of bad) {
      const t = triangles[j];
      for (let e = 0; e < 3; e++) {
        const a = t[e], b = t[(e+1)%3];
        const shared = bad.some(k => k !== j && {
          const t2 = triangles[k];
          return (t2.includes(a) && t2.includes(b));
        }.call());
        // Simplified: add edge if not shared by another bad triangle
        let isShared = false;
        for (const k of bad) {
          if (k === j) continue;
          const t2 = triangles[k];
          if (t2.includes(a) && t2.includes(b)) { isShared = true; break; }
        }
        if (!isShared) edges.push([a, b]);
      }
    }

    // Remove bad triangles (reverse order)
    bad.sort((a, b) => b - a);
    for (const j of bad) triangles.splice(j, 1);

    // Create new triangles from boundary edges to new point
    for (const [a, b] of edges) triangles.push([a, b, i]);
  }

  // Remove triangles connected to super-triangle
  return triangles.filter(t => t[0] > 2 && t[1] > 2 && t[2] > 2)
    .map(t => [t[0]-3, t[1]-3, t[2]-3]);
}

const tris = delaunay(points);
let time = 0;

function draw() {
  ctx.fillStyle = '#0a0a0a';
  ctx.fillRect(0, 0, 800, 600);

  // Draw Voronoi-style colored triangles
  tris.forEach((t, i) => {
    const [a, b, c] = t.map(j => points[j]);
    ctx.beginPath();
    ctx.moveTo(a[0], a[1]);
    ctx.lineTo(b[0], b[1]);
    ctx.lineTo(c[0], c[1]);
    ctx.closePath();
    const hue = (i / tris.length * 360 + time) % 360;
    ctx.fillStyle = `hsla(${hue}, 50%, 25%, 0.6)`;
    ctx.fill();
    ctx.strokeStyle = `hsla(${hue}, 70%, 60%, 0.8)`;
    ctx.lineWidth = 1;
    ctx.stroke();
  });

  // Draw circumcircles (faintly)
  tris.forEach(t => {
    const [ax, ay] = points[t[0]], [bx, by] = points[t[1]], [cx, cy] = points[t[2]];
    const D = 2 * (ax * (by-cy) + bx * (cy-ay) + cx * (ay-by));
    if (Math.abs(D) < 1e-10) return;
    const ux = ((ax*ax+ay*ay)*(by-cy) + (bx*bx+by*by)*(cy-ay) + (cx*cx+cy*cy)*(ay-by)) / D;
    const uy = ((ax*ax+ay*ay)*(cx-bx) + (bx*bx+by*by)*(ax-cx) + (cx*cx+cy*cy)*(bx-ax)) / D;
    const r = Math.sqrt((ax-ux)*(ax-ux) + (ay-uy)*(ay-uy));
    ctx.beginPath();
    ctx.arc(ux, uy, r, 0, Math.PI * 2);
    ctx.strokeStyle = 'rgba(255,255,255,0.08)';
    ctx.lineWidth = 0.5;
    ctx.stroke();
  });

  // Draw points
  points.forEach(p => {
    ctx.beginPath();
    ctx.arc(p[0], p[1], 3, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
  });

  time += 0.3;
  requestAnimationFrame(draw);
}
draw();

The Bowyer-Watson algorithm shown here is the standard incremental approach: for each new point, find all triangles whose circumcircle contains it (forming a "cavity"), remove them, and retriangulate the cavity with the new point. The result is always a valid Delaunay triangulation. In practice, production code uses Fortune's sweep line algorithm (O(n log n)) or the divide-and-conquer approach, but Bowyer-Watson is the most intuitive to implement and understand.

6. Voronoi mosaic — image filter

One of the most visually striking applications: seed a Voronoi diagram over an image and color each cell with the average color of the pixels inside it. The result is a stained-glass mosaic effect that reduces a photograph to its essential color regions. More seeds = more detail. Fewer seeds = more abstract.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Create a colorful gradient image to mosaic
const gradient = ctx.createLinearGradient(0, 0, 800, 600);
gradient.addColorStop(0, '#ff6b6b');
gradient.addColorStop(0.25, '#ffd93d');
gradient.addColorStop(0.5, '#6bcb77');
gradient.addColorStop(0.75, '#4d96ff');
gradient.addColorStop(1, '#9b59b6');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 800, 600);

// Add some circles for texture
for (let i = 0; i < 20; i++) {
  const x = Math.random() * 800, y = Math.random() * 600;
  const r = 30 + Math.random() * 100;
  const g2 = ctx.createRadialGradient(x, y, 0, x, y, r);
  g2.addColorStop(0, `hsla(${Math.random()*360}, 80%, 60%, 0.6)`);
  g2.addColorStop(1, 'transparent');
  ctx.fillStyle = g2;
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fill();
}

// Capture source image
const srcData = ctx.getImageData(0, 0, 800, 600).data;

// Generate seeds
const N = 200;
const seeds = [];
for (let i = 0; i < N; i++) {
  seeds.push({ x: Math.random() * 800, y: Math.random() * 600 });
}

// Apply Lloyd relaxation for more even cells
for (let iter = 0; iter < 10; iter++) {
  const cx = new Float64Array(N), cy = new Float64Array(N);
  const count = new Uint32Array(N);
  // Sample a grid instead of every pixel for speed
  for (let py = 0; py < 600; py += 4) {
    for (let px = 0; px < 800; px += 4) {
      let minD = Infinity, closest = 0;
      for (let i = 0; i < N; i++) {
        const dx = px - seeds[i].x, dy = py - seeds[i].y;
        const d = dx * dx + dy * dy;
        if (d < minD) { minD = d; closest = i; }
      }
      cx[closest] += px;
      cy[closest] += py;
      count[closest]++;
    }
  }
  for (let i = 0; i < N; i++) {
    if (count[i] > 0) {
      seeds[i].x = cx[i] / count[i];
      seeds[i].y = cy[i] / count[i];
    }
  }
}

// Compute Voronoi mosaic
const cellR = new Float64Array(N), cellG = new Float64Array(N);
const cellB = new Float64Array(N), cellCount = new Uint32Array(N);
const cellMap = new Uint16Array(800 * 600);

for (let py = 0; py < 600; py++) {
  for (let px = 0; px < 800; px++) {
    let minD = Infinity, closest = 0;
    for (let i = 0; i < N; i++) {
      const dx = px - seeds[i].x, dy = py - seeds[i].y;
      const d = dx * dx + dy * dy;
      if (d < minD) { minD = d; closest = i; }
    }
    cellMap[py * 800 + px] = closest;
    const si = (py * 800 + px) * 4;
    cellR[closest] += srcData[si];
    cellG[closest] += srcData[si+1];
    cellB[closest] += srcData[si+2];
    cellCount[closest]++;
  }
}

// Draw mosaic
const out = ctx.createImageData(800, 600);
for (let py = 0; py < 600; py++) {
  for (let px = 0; px < 800; px++) {
    const c = cellMap[py * 800 + px];
    const idx = (py * 800 + px) * 4;
    out.data[idx] = cellR[c] / cellCount[c];
    out.data[idx+1] = cellG[c] / cellCount[c];
    out.data[idx+2] = cellB[c] / cellCount[c];
    out.data[idx+3] = 255;
  }
}
ctx.putImageData(out, 0, 0);

// Draw subtle cell edges
seeds.forEach((s, i) => {
  ctx.beginPath();
  ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(255,255,255,0.4)';
  ctx.fill();
});

The Lloyd relaxation step is crucial — it makes the cells more uniform, so the mosaic looks like actual stained glass rather than a random shatter pattern. This technique is used in non-photorealistic rendering, artistic filters in image editors, and data visualization where you need to reduce a map to discrete regions while preserving color relationships.

7. Crystal growth simulation

Voronoi diagrams naturally model crystal grain boundaries. In polycrystalline materials, each crystal nucleates at a point and grows outward until it meets its neighbors. The boundaries between crystals are exactly the Voronoi edges. By animating this growth process — expanding circles from each seed — we can simulate crystallization in real time.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 35;
const seeds = [];
for (let i = 0; i < N; i++) {
  seeds.push({
    x: Math.random() * 800,
    y: Math.random() * 600,
    hue: (i / N) * 360,
    rate: 0.6 + Math.random() * 0.8 // growth rate varies
  });
}

const claimed = new Int16Array(800 * 600).fill(-1);
let radius = 0;

function grow() {
  ctx.fillStyle = '#0a0a0a';
  ctx.fillRect(0, 0, 800, 600);

  radius += 1.5;
  let changed = false;

  // Expand each crystal by its growth rate
  for (let py = 0; py < 600; py++) {
    for (let px = 0; px < 800; px++) {
      if (claimed[py * 800 + px] >= 0) continue;
      for (let i = 0; i < N; i++) {
        const dx = px - seeds[i].x, dy = py - seeds[i].y;
        const d = Math.sqrt(dx * dx + dy * dy);
        if (d < radius * seeds[i].rate) {
          claimed[py * 800 + px] = i;
          changed = true;
          break;
        }
      }
    }
  }

  // Draw crystals
  const img = ctx.createImageData(800, 600);
  for (let py = 0; py < 600; py++) {
    for (let px = 0; px < 800; px++) {
      const c = claimed[py * 800 + px];
      const idx = (py * 800 + px) * 4;
      if (c < 0) {
        img.data[idx] = img.data[idx+1] = img.data[idx+2] = 10;
      } else {
        const h = seeds[c].hue / 60;
        const ch = 0.45, x2 = ch * (1 - Math.abs(h % 2 - 1)), m = 0.15;
        let r = m, g = m, b = m;
        if (h < 1) { r += ch; g += x2; }
        else if (h < 2) { r += x2; g += ch; }
        else if (h < 3) { g += ch; b += x2; }
        else if (h < 4) { g += x2; b += ch; }
        else if (h < 5) { r += x2; b += ch; }
        else { r += ch; b += x2; }
        img.data[idx] = r * 255;
        img.data[idx+1] = g * 255;
        img.data[idx+2] = b * 255;
      }
      img.data[idx+3] = 255;

      // Edge detection for grain boundaries
      if (c >= 0 && px > 0 && py > 0) {
        const left = claimed[py * 800 + px - 1];
        const up = claimed[(py-1) * 800 + px];
        if ((left >= 0 && left !== c) || (up >= 0 && up !== c)) {
          img.data[idx] = 200;
          img.data[idx+1] = 200;
          img.data[idx+2] = 200;
        }
      }
    }
  }
  ctx.putImageData(img, 0, 0);

  // Draw nucleation points
  seeds.forEach(s => {
    ctx.beginPath();
    ctx.arc(s.x, s.y, 2, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
  });

  ctx.fillStyle = '#fff';
  ctx.font = '14px monospace';
  ctx.fillText('Growth radius: ' + radius.toFixed(0), 20, 25);

  if (changed) requestAnimationFrame(grow);
}
grow();

The varying growth rates create an additive weighted Voronoi diagram — crystals that grow faster (higher temperature nucleation sites, for example) claim more territory. This is exactly how polycrystalline metals, ice, and geological formations develop their characteristic grain patterns. The bright edges between crystals are grain boundaries — the weakest points in a material and the places where corrosion, cracking, and deformation begin.

8. Generative Voronoi art — layered composition

The final piece combines everything: multiple Voronoi layers at different scales, distance-based shading, animated seeds, and color harmonies. The result is an organic, slowly evolving artwork that looks like a living stained-glass window.

const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Two layers of seeds at different scales
const layer1 = [], layer2 = [];
for (let i = 0; i < 15; i++) {
  layer1.push({
    x: Math.random() * 800, y: Math.random() * 600,
    vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5,
    hue: (i / 15) * 360
  });
}
for (let i = 0; i < 60; i++) {
  layer2.push({
    x: Math.random() * 800, y: Math.random() * 600,
    vx: (Math.random() - 0.5) * 0.3, vy: (Math.random() - 0.5) * 0.3,
    hue: (i / 60) * 360
  });
}

let time = 0;

function animate() {
  // Move seeds
  [...layer1, ...layer2].forEach(s => {
    s.x += s.vx; s.y += s.vy;
    if (s.x < 0 || s.x > 800) s.vx *= -1;
    if (s.y < 0 || s.y > 600) s.vy *= -1;
    s.x = Math.max(0, Math.min(800, s.x));
    s.y = Math.max(0, Math.min(600, s.y));
  });

  const img = ctx.createImageData(800, 600);

  // Render every 2nd pixel for performance, then stretch
  for (let py = 0; py < 600; py += 2) {
    for (let px = 0; px < 800; px += 2) {
      // Layer 1: large cells, base color
      let min1 = Infinity, close1 = 0, second1 = Infinity;
      for (let i = 0; i < layer1.length; i++) {
        const dx = px - layer1[i].x, dy = py - layer1[i].y;
        const d = Math.sqrt(dx * dx + dy * dy);
        if (d < min1) { second1 = min1; min1 = d; close1 = i; }
        else if (d < second1) second1 = d;
      }

      // Layer 2: small cells, detail
      let min2 = Infinity, close2 = 0;
      for (let i = 0; i < layer2.length; i++) {
        const dx = px - layer2[i].x, dy = py - layer2[i].y;
        const d = Math.sqrt(dx * dx + dy * dy);
        if (d < min2) { min2 = d; close2 = i; }
      }

      // Edge glow: brighter near Voronoi edges (where second-nearest is close)
      const edgeDist = second1 - min1;
      const edgeGlow = Math.exp(-edgeDist * 0.03);

      // Combine layers
      const hue = (layer1[close1].hue * 0.6 + layer2[close2].hue * 0.4 + time * 5) % 360;
      const sat = 0.5 + edgeGlow * 0.3;
      const lum = 0.15 + (1 - min2 / 150) * 0.2 + edgeGlow * 0.4;

      // HSL to RGB
      const h = hue / 60;
      const c = (1 - Math.abs(2 * lum - 1)) * sat;
      const x2 = c * (1 - Math.abs(h % 2 - 1));
      const m = lum - c / 2;
      let r = m, g = m, b = m;
      if (h < 1) { r += c; g += x2; }
      else if (h < 2) { r += x2; g += c; }
      else if (h < 3) { g += c; b += x2; }
      else if (h < 4) { g += x2; b += c; }
      else if (h < 5) { r += x2; b += c; }
      else { r += c; b += x2; }

      // Write 2x2 block
      for (let dy2 = 0; dy2 < 2 && py+dy2 < 600; dy2++) {
        for (let dx2 = 0; dx2 < 2 && px+dx2 < 800; dx2++) {
          const idx = ((py+dy2) * 800 + px+dx2) * 4;
          img.data[idx] = Math.min(255, r * 255);
          img.data[idx+1] = Math.min(255, g * 255);
          img.data[idx+2] = Math.min(255, b * 255);
          img.data[idx+3] = 255;
        }
      }
    }
  }

  ctx.putImageData(img, 0, 0);
  time += 0.016;
  requestAnimationFrame(animate);
}
animate();

The dual-layer technique — large cells for base color, small cells for detail — mimics how natural materials work. A giraffe's spots have large-scale Voronoi regions for the patch boundaries, with finer texture inside each patch. Dragonfly wings show Voronoi-like venation at multiple scales. By computing the distance to both the nearest and second-nearest seed, we get edge proximity for free, creating the luminous borders that make the piece look like stained glass.

The mathematics of proximity

Voronoi diagrams sit at the intersection of several deep mathematical fields:

  • Computational geometry: Fortune's sweep line algorithm computes a Voronoi diagram in O(n log n) — a theoretical optimum. The Delaunay triangulation (dual graph) has the "empty circumcircle" property and maximizes the minimum angle among all possible triangulations.
  • Topology: Voronoi vertices (where three or more cells meet) have degree 3 in general position. Degenerate cases (four co-circular points) create degree-4 vertices. The Euler formula V - E + F = 2 constrains the structure.
  • Optimization: Lloyd relaxation converges to a centroidal Voronoi tessellation (CVT) that minimizes the total squared distance from each point to its cell's centroid. CVTs are optimal for quantization, sampling, and resource allocation.
  • Physics: Wigner-Seitz cells in crystallography are Voronoi cells of lattice points. Brillouin zones (momentum-space Voronoi cells) determine the electronic band structure of materials.

Voronoi in nature — a gallery

The Voronoi pattern appears across scales and domains:

  • Giraffe spots: the dark boundaries between orange patches follow Voronoi edges almost exactly, created by melanin-producing cells competing for territory during embryonic development
  • Dragonfly wings: the venation pattern is a Voronoi diagram of the wing's structural support points
  • Soap bubbles: when bubbles press together in a foam, each bubble's face boundary minimizes surface energy — forming a 3D Voronoi structure
  • Cracked mud: desiccation cracks in dried mud form a Voronoi-like pattern around nucleation points where shrinkage began
  • Corn kernels: each kernel grows from a point on the cob, and they pack into a Voronoi arrangement
  • Turtle shells: the scute pattern on many tortoise species follows Voronoi geometry
  • Honeycomb: the hexagonal structure is the special case of a Voronoi diagram on a regular lattice — the centroidal Voronoi tessellation of a plane converges to hexagons
  • Basalt columns: Giant's Causeway and similar geological formations show columnar joints that are Voronoi prisms formed by uniform cooling contraction

Where to go from here

  • Explore tessellation art for more tiling patterns — Penrose tiles, Islamic geometry, and Escher-style transformations
  • Create procedural worlds using Voronoi for biome boundaries, island coastlines, and dungeon room shapes
  • Combine Voronoi with mathematical art techniques — strange attractors as seed point generators, or rose curves controlling cell colors
  • Add Perlin noise displacement to Voronoi edges for organic, natural-looking cell boundaries
  • Use color theory to create harmonious Voronoi palettes — analogous colors within cells, complementary colors across boundaries
  • Apply Voronoi mosaics to particle systems — each particle defines a cell, creating an organic, shifting tessellation that breathes with the motion
  • On Lumitree, several micro-worlds use Voronoi patterns — crystal caves with gem-like Voronoi facets, terrain generators that use Voronoi for geological region boundaries, and abstract art pieces built on Lloyd-relaxed tessellations

Related articles

Tessellation Art: How to Create Mesmerizing Tiling Patterns With Code
Learn to create tessellation art with JavaScript and Canvas. 8 interactive examples: regular tilings, Escher-style transformations, Penrose tilings, hyperbolic tessellations, Voronoi cells, Islamic star patterns, aperiodic monotiles, and animated morphing tiles.
Procedural Generation: How to Create Infinite Worlds With Code
A complete guide to procedural generation — the algorithms behind infinite game worlds, generative art, and adaptive content. Covers noise-based terrain, wave function collapse, cellular automata, BSP dungeons, L-systems, Poisson disc sampling, random walks, and marching squares. 8 working code examples.
Math Art: How to Create Beautiful Mathematical Patterns With Code
A hands-on guide to math art — how to turn mathematical formulas into stunning visual art using JavaScript. Covers spirals, tilings, Lissajous curves, rose curves, polar patterns, the golden ratio, and more, with working code examples you can run in your browser.
Stippling Art: How to Create Beautiful Dot-Based Art With Code
Learn to create stunning stippling art programmatically with JavaScript and Canvas. 8 interactive examples: random stippling, Poisson disc sampling, weighted stippling, Lloyd relaxation, image-to-dots converter, cross-hatching, stipple portrait, and generative stipple art composition.
Low Poly Art: How to Create Stunning Geometric Landscapes With Code
Learn to create beautiful low poly art programmatically with JavaScript and Canvas. 8 interactive examples: random triangulation, Delaunay triangulation, image-to-low-poly converter, gradient mesh terrain, animated low poly waves, low poly portrait generator, low poly planet, and generative low poly landscape composition.
Circle Packing: How to Create Beautiful Space-Filling Art With Code
Learn to build mesmerizing circle packing art with JavaScript and Canvas. 8 working examples: basic circle packing, image-based packing, recursive Apollonian gasket, organic growth simulation, text-filled circles, interactive packing, circle packing tree visualization, and generative circle packing art.