← Generative art

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

  1. Paste the snippet just before </head>.
  2. Add data-circle-packing to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-colorsComma-separated circle colours. Default violets, pinks, amber and sky.
data-maxLargest radius in px. Default a fifth of the box’s shorter side.
data-minSmallest radius in px: spots with less room are skipped. Default 2.
data-gapSpace between circles in px. Default 2.
data-stylefill or ring (outlines only). Default fill.
data-speedSpeed multiplier. Default 1.
data-loopHold 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.

1 · Snippet
<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>
2 · Your HTML
<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>