← Generative art

Firefly Sync

A meadow of fireflies, each flashing to its own rhythm. Whenever one flashes, its neighbours hurry their next flash along a little, and bit by bit the field falls into step until they all blink together, as some real fireflies do. Tap or click to startle the ones nearby out of step again.

Tap to startle them

How to use it

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

Options

Add any of these attributes to the same element.

data-loopOnce they’ve flashed together for a few seconds, scatter their rhythms and start again.
data-countHow many fireflies. Default scaled to the box’s area, 24 to 110.
data-periodTime between one firefly’s flashes. Default 1100 ms.
data-couplingHow hard a flash nudges the neighbours on. Higher falls into step sooner. Default 0.08.
data-colorGlow colour. Default #d9f99d.
data-labelShow how in step they are, as a percentage, in the corner.

It’s the Mirollo–Strogatz model of pulse-coupled oscillators: a flash pushes each neighbour’s clock forward in proportion to how far along it already is, so stragglers are pulled into a group and groups merge. It’s paused when off screen, and with reduced motion it shows them all lit at once.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Firefly Sync: a meadow of fireflies, each flashing on its own rhythm.
    // Whenever one flashes, its neighbours hurry their own next flash along
    // a little, and bit by bit the whole field falls into step until they
    // all blink together, as real fireflies do. Tap or click to startle the
    // ones nearby out of step again.
    // Usage: <div data-firefly-sync data-loop style="height:360px;background:#06120e"></div>
    // Optional: data-count (default scaled to the box's area, 24 to 110),
    // data-color (glow colour, default #d9f99d), data-period (ms between
    // flashes, default 1100), data-coupling (how strongly a flash nudges the
    // neighbours, default 0.08), data-loop (once they've been in step for a
    // while, scatter them and start again), data-label (show how in step
    // they are, as a percentage).
    var DEFAULTS = { color: '#d9f99d', period: 1100, coupling: 0.08 };

    var css =
      '[data-firefly-sync]{position:relative;isolation:isolate;overflow:hidden}' +
      '.ffs-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}' +
      '.ffs-label{position:absolute;right:12px;top:10px;font:500 12px/1 ui-monospace,Menlo,Consolas,monospace;' +
      'color:rgba(236,252,203,.7);pointer-events:none;letter-spacing:.04em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // A pre-drawn glow: a bright core inside a soft halo.
    function sprite(color) {
      var S = 96, c = document.createElement('canvas');
      c.width = c.height = S;
      var g = c.getContext('2d');
      g.fillStyle = color;
      g.fillRect(0, 0, S, S);
      g.globalCompositeOperation = 'destination-in';
      var m = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
      m.addColorStop(0, '#000');
      m.addColorStop(0.1, '#000');
      m.addColorStop(0.25, 'rgba(0,0,0,.5)');
      m.addColorStop(0.55, 'rgba(0,0,0,.15)');
      m.addColorStop(1, 'rgba(0,0,0,0)');
      g.fillStyle = m;
      g.fillRect(0, 0, S, S);
      g.globalCompositeOperation = 'source-over';
      var core = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S * 0.07);
      core.addColorStop(0, 'rgba(255,255,255,.95)');
      core.addColorStop(1, 'rgba(255,255,255,0)');
      g.fillStyle = core;
      g.fillRect(0, 0, S, S);
      return c;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'ffs-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var color = d.color || DEFAULTS.color;
      var img = sprite(color);
      var period = +d.period || DEFAULTS.period;
      var coupling = d.coupling !== undefined ? +d.coupling : DEFAULTS.coupling;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var label = null;
      if (d.label !== undefined && d.label !== 'false' && !el.closest('[aria-hidden="true"]')) {
        label = document.createElement('span');
        label.className = 'ffs-label';
        label.setAttribute('aria-hidden', 'true');
        el.appendChild(label);
      }
      var w = 0, h = 0, dpr = 1, flies = [], reach = 0, eps = coupling, inStep = 0, order = 0, shown = -1;

      function fly() {
        return {
          x: Math.random() * w, y: h * 0.12 + Math.random() * h * 0.82,
          ph: Math.random(),                         // where it is in its cycle, 0 to 1
          rate: 1 + (Math.random() - 0.5) * 0.06,    // each has a slightly different natural rhythm
          glow: 0,
          a: Math.random() * Math.PI * 2, v: 0.006 + Math.random() * 0.01,
          size: 30 + Math.random() * 24
        };
      }

      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);
        reach = Math.sqrt(w * w + h * h) * 0.3;
        var n = +d.count || Math.round(Math.max(24, Math.min(110, w * h / 3200)));
        flies = [];
        for (var i = 0; i < n; i++) flies.push(fly());
        // A crowded field gets a smaller nudge per flash, so it falls into
        // step at about the same pace as a sparse one.
        var near = Math.min(n, n * Math.PI * reach * reach / (w * h));
        eps = coupling * Math.min(1, 10 / near);
        return true;
      }

      function scatter(x, y, r) {
        for (var i = 0; i < flies.length; i++) {
          var f = flies[i];
          if (r && (f.x - x) * (f.x - x) + (f.y - y) * (f.y - y) > r * r) continue;
          f.ph = Math.random();
        }
        inStep = 0;
      }

      function update(dt) {
        var k = dt / period, fired = [], i, f;
        for (i = 0; i < flies.length; i++) {
          f = flies[i];
          f.ph += k * f.rate;
          f.glow = Math.max(0, f.glow - dt / 420);
          if (f.ph >= 1) fired.push(i);
          // A lazy drift, turning a little at random.
          f.a += (Math.random() - 0.5) * 0.2;
          f.x += Math.cos(f.a) * f.v * dt;
          f.y += Math.sin(f.a) * f.v * dt * 0.6;
          if (f.x < -10) f.x = w + 10; else if (f.x > w + 10) f.x = -10;
          if (f.y < h * 0.08) f.a = Math.abs(f.a); else if (f.y > h * 0.96) f.a = -Math.abs(f.a);
        }
        // Each flash nudges the neighbours ahead; any pushed past the end of
        // their cycle flash too, straight away.
        while (fired.length) {
          var j = fired.shift(), src = flies[j];
          if (src.ph < 1) continue;
          src.ph = 0;
          src.glow = 1;
          for (i = 0; i < flies.length; i++) {
            f = flies[i];
            if (f.ph <= 0 || f.ph >= 1) continue;
            var dx = f.x - src.x, dy = f.y - src.y;
            if (dx * dx + dy * dy > reach * reach) continue;
            f.ph = Math.min(1, f.ph * (1 + eps) + eps * 0.08);
            if (f.ph >= 1) fired.push(i);
          }
        }
        // How in step are they? 1 when every cycle lines up.
        var c = 0, s = 0;
        for (i = 0; i < flies.length; i++) { c += Math.cos(flies[i].ph * 6.2832); s += Math.sin(flies[i].ph * 6.2832); }
        order = Math.sqrt(c * c + s * s) / flies.length;
        if (order > 0.96) inStep += dt; else inStep = 0;
        if (loop && inStep > 6000) scatter();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        for (var i = 0; i < flies.length; i++) {
          var f = flies[i];
          // A faint body when dark, a bright bloom when flashing.
          ctx.globalAlpha = 0.3;
          ctx.drawImage(img, f.x - 5, f.y - 5, 10, 10);
          if (f.glow > 0.01) {
            var s = f.size * (0.6 + f.glow * 0.6);
            ctx.globalAlpha = f.glow;
            ctx.drawImage(img, f.x - s / 2, f.y - s / 2, s, s);
          }
        }
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
        if (label) {
          var pct = Math.round(order * 100);
          if (pct !== shown) { shown = pct; label.textContent = 'in step ' + pct + '%'; }
        }
      }

      resize();
      if (reduced()) {
        // Show them flashing together, as they end up.
        flies.forEach(function (f) { f.glow = 1; f.ph = 0; });
        order = 1;
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () {
          if (resize()) { flies.forEach(function (f) { f.glow = 1; f.ph = 0; }); draw(); }
        }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
      el.addEventListener('pointerdown', function (e) {
        var b = el.getBoundingClientRect();
        scatter(e.clientX - b.left, e.clientY - b.top, reach * 0.8);
      });

      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;
          update(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-firefly-sync]').forEach(function (el) {
        if (el._ffs) return;
        el._ffs = true;
        build(el);
      });
    }

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