← Generative art

Boids Flock

A flock of little birds swirls about the box. Each one keeps clear of its nearest neighbours, flies the same way as the birds around it and drifts towards the middle of its group, and together they make a murmuration. They dodge the pointer, and a tap scatters them.

Tap to scatter them

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-boids-flock to a box with a height and a light background.

Options

Add any of these attributes to the same element.

data-countNumber of birds. Default about one per 50 × 50 px, 30 to 260.
data-colorBird colour. Default #2a1838.
data-speedSpeed multiplier. Default 1.
data-scatter="false"Ignore the pointer.

They’re Craig Reynolds’ “boids” from 1986, drawn on a transparent canvas and paused when off screen. With reduced motion the flock stays still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Boids Flock: a flock of little birds swirls about the box. Each one
    // follows three rules: keep clear of its nearest neighbours, fly the same
    // way as the birds around it, and drift towards the middle of its group.
    // Together they make a murmuration. Birds flee from the pointer, and a tap
    // scatters them. Drawn on a transparent canvas and paused when off screen.
    // Usage: <div data-boids-flock style="height:360px;background:#fde2c4"></div>
    // Optional: data-count (default about one per 2,500 px², 30 to 260),
    // data-color (default #2a1838), data-speed (default 1),
    // data-scatter="false" (ignore the pointer).
    var DEFAULTS = { color: '#2a1838', speed: 1 };
    var SIGHT = 46, SPACE = 16;

    var css =
      '[data-boids-flock]{position:relative;isolation:isolate;overflow:hidden}' +
      '.bds-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 = 'bds-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var color = d.color || DEFAULTS.color;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var w = 0, h = 0, dpr = 1, t = 0;
      var birds = [], pointer = null, gridCols = 0, gridRows = 0, buckets = [];

      function bird() {
        var a = Math.random() * Math.PI * 2;
        return {
          x: Math.random() * w, y: Math.random() * h,
          vx: Math.cos(a) * 2, vy: Math.sin(a) * 2,
          // Smaller, paler birds look further away.
          depth: 0.55 + Math.random() * 0.45,
          flap: Math.random() * Math.PI * 2
        };
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return;
        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.min(260, Math.max(30, (w * h) / 2500)));
        while (birds.length < n) birds.push(bird());
        birds.length = n;
        birds.forEach(function (b) { b.x = Math.min(b.x, w); b.y = Math.min(b.y, h); });
      }

      // Sort the birds into a grid of SIGHT-sized buckets, so each only checks those nearby.
      function bucket() {
        gridCols = Math.ceil(w / SIGHT) + 1;
        gridRows = Math.ceil(h / SIGHT) + 1;
        buckets = [];
        for (var i = 0; i < birds.length; i++) {
          var b = birds[i];
          var k = Math.max(0, Math.min(gridRows - 1, Math.floor(b.y / SIGHT))) * gridCols +
            Math.max(0, Math.min(gridCols - 1, Math.floor(b.x / SIGHT)));
          (buckets[k] || (buckets[k] = [])).push(b);
        }
      }

      function advance(dt) {
        var f = dt / 16.67 * speed;
        t += dt;
        bucket();
        for (var i = 0; i < birds.length; i++) {
          var b = birds[i], gx = Math.floor(b.x / SIGHT), gy = Math.floor(b.y / SIGHT);
          var ax = 0, ay = 0, cx = 0, cy = 0, sx = 0, sy = 0, n = 0;
          for (var y = gy - 1; y <= gy + 1; y++) {
            if (y < 0 || y >= gridRows) continue;
            for (var x = gx - 1; x <= gx + 1; x++) {
              if (x < 0 || x >= gridCols) continue;
              var list = buckets[y * gridCols + x];
              if (!list) continue;
              for (var j = 0; j < list.length; j++) {
                var o = list[j];
                if (o === b) continue;
                var dx = o.x - b.x, dy = o.y - b.y, dd = dx * dx + dy * dy;
                if (dd > SIGHT * SIGHT) continue;
                ax += o.vx; ay += o.vy; cx += o.x; cy += o.y; n++;
                if (dd < SPACE * SPACE && dd > 0) { var s = 1 / Math.sqrt(dd); sx -= dx * s; sy -= dy * s; }
              }
            }
          }
          if (n) {
            // Fly with them, towards their middle, and not too close.
            b.vx += ((ax / n - b.vx) * 0.05 + (cx / n - b.x) * 0.0012 + sx * 0.12) * f;
            b.vy += ((ay / n - b.vy) * 0.05 + (cy / n - b.y) * 0.0012 + sy * 0.12) * f;
          }
          // Turn back in near the edges.
          var m = Math.min(60, w * 0.12, h * 0.12), turn = 0.16 * f;
          if (b.x < m) b.vx += turn; else if (b.x > w - m) b.vx -= turn;
          if (b.y < m) b.vy += turn; else if (b.y > h - m) b.vy -= turn;
          // Flee the pointer.
          if (pointer) {
            var px = b.x - pointer.x, py = b.y - pointer.y, pd = px * px + py * py, R = pointer.r;
            if (pd < R * R && pd > 0) {
              var push = (1 - Math.sqrt(pd) / R) * pointer.force * f / Math.sqrt(pd);
              b.vx += px * push; b.vy += py * push;
            }
          }
          // A gentle wander, so the flock never settles into one loop.
          b.vx += Math.sin(t / 1700 + b.y / 90) * 0.02 * f;
          b.vy += Math.cos(t / 2100 + b.x / 110) * 0.02 * f;
          var v = Math.sqrt(b.vx * b.vx + b.vy * b.vy), lo = 1.6, hi = pointer && pointer.force > 1 ? 5 : 3.2;
          if (v > hi) { b.vx *= hi / v; b.vy *= hi / v; } else if (v < lo && v > 0) { b.vx *= lo / v; b.vy *= lo / v; }
          b.x += b.vx * f;
          b.y += b.vy * f;
          b.flap += (0.25 + v * 0.05) * f;
        }
        if (pointer && pointer.burst) {
          pointer.force *= Math.pow(0.9, f);
          if (pointer.force < 0.2) pointer = null;
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.fillStyle = color;
        var unit = Math.max(0.8, Math.min(1.25, Math.min(w, h) / 300));
        for (var i = 0; i < birds.length; i++) {
          var b = birds[i], a = Math.atan2(b.vy, b.vx), s = 6 * b.depth * unit;
          // A bird is a chevron whose wings beat in and out.
          var wing = 0.55 + 0.35 * Math.sin(b.flap);
          var c = Math.cos(a), sn = Math.sin(a);
          ctx.globalAlpha = 0.45 + 0.55 * b.depth;
          ctx.beginPath();
          ctx.moveTo(b.x + c * s, b.y + sn * s);
          ctx.lineTo(b.x - c * s * 0.7 - sn * s * wing, b.y - sn * s * 0.7 + c * s * wing);
          ctx.lineTo(b.x - c * s * 0.25, b.y - sn * s * 0.25);
          ctx.lineTo(b.x - c * s * 0.7 + sn * s * wing, b.y - sn * s * 0.7 - c * s * wing);
          ctx.closePath();
          ctx.fill();
        }
        ctx.globalAlpha = 1;
      }

      resize();
      if (reduced()) {
        // A still frame of the flock, already gathered.
        for (var k = 0; k < 300; k++) advance(16.67);
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      if (d.scatter !== 'false') {
        el.addEventListener('pointermove', function (e) {
          if (e.pointerType !== 'mouse' || (pointer && pointer.burst)) return;
          var r = el.getBoundingClientRect();
          pointer = { x: e.clientX - r.left, y: e.clientY - r.top, r: 90, force: 0.35 };
        });
        el.addEventListener('pointerleave', function () { if (pointer && !pointer.burst) pointer = null; });
        // A tap (or click) scatters every bird nearby.
        el.addEventListener('pointerdown', function (e) {
          var r = el.getBoundingClientRect();
          pointer = { x: e.clientX - r.left, y: e.clientY - r.top, r: 170, force: 2.2, burst: true };
        });
      }

      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;
          advance(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-boids-flock]').forEach(function (el) {
        if (el._bds) return;
        el._bds = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-boids-flock style="height: 360px; background: #fde2c4"></div>