← Seasonal

Flying Bats

A colony of bats bursts out of one corner of a section and flaps across it, swooping and growing as they come closer, then leaves past the far edge. They fly over your content without getting in the way of clicks.

Happy Halloween

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-flying-bats to any section. The bats fly out when it scrolls into view.

Options

Add any of these attributes to the same element.

data-loopSend another flight every few seconds.
data-fromThe corner they come from: top-right, top-left, bottom-left or bottom-right. Default top-right.
data-countBats per flight. Default 9.
data-colorBat colour. Default near-black #120b1f.
data-sizeWingspan in px. Default scales with the section.

The bats are clipped to the section and pause when it is off screen. With reduced motion there are no bats.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Flying Bats: a colony of bats bursts out of one corner of a section and
    // flaps across it, swooping and growing as they come closer, then leaves
    // past the far edge. They fly over your content without blocking clicks.
    // Usage: <section data-flying-bats>…</section>
    // Optional: data-loop (another flight leaves every few seconds), data-from
    // (the corner they come from: "top-right", default, "top-left",
    // "bottom-left" or "bottom-right"), data-count (bats per flight, default 9),
    // data-color (default near-black #120b1f), data-size (wingspan in px,
    // default scales with the section).
    var DEFAULTS = { from: 'top-right', count: 9, color: '#120b1f' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '[data-flying-bats]{position:relative}' +
      '.fbt-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:2}' +
      '.fbt-bat{position:absolute;left:0;top:0;overflow:visible;transform-origin:50% 50%;will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // A bat 64 units wide, centred on its body. The wings are one group so
    // squashing it vertically makes them flap.
    function batSvg(color) {
      var wing = 'M-3 -2 Q-12 -14 -31 -11 Q-27 -5 -26 0 Q-22 -3 -18 1 Q-14 -2 -10 3.5 Q-6 1.5 -3 4 Z';
      return '<g class="fbt-wings"><path d="' + wing + '" fill="' + color + '"/>' +
        '<path d="' + wing + '" transform="scale(-1 1)" fill="' + color + '"/></g>' +
        '<ellipse cx="0" cy="1" rx="4" ry="6.5" fill="' + color + '"/>' +
        '<path d="M-3.4 -3.5 L-3 -9 L-1 -5.2 L1 -5.2 L3 -9 L3.4 -3.5 Z" fill="' + color + '"/>' +
        '<circle cx="-1.4" cy="-2.6" r=".7" fill="#fca5a5"/><circle cx="1.4" cy="-2.6" r=".7" fill="#fca5a5"/>';
    }

    function build(el) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var from = d.from || DEFAULTS.from;
      var count = Math.max(1, +d.count || DEFAULTS.count);
      var color = d.color || DEFAULTS.color;

      var layer = document.createElement('div');
      layer.className = 'fbt-layer';
      layer.setAttribute('aria-hidden', 'true');
      el.appendChild(layer);

      var bats = [];
      for (var i = 0; i < count; i++) {
        var s = document.createElementNS(NS, 'svg');
        s.setAttribute('class', 'fbt-bat');
        s.setAttribute('viewBox', '-32 -16 64 32');
        s.setAttribute('focusable', 'false');
        s.innerHTML = batSvg(color);
        s.style.visibility = 'hidden';
        layer.appendChild(s);
        bats.push({ el: s, wings: s.firstChild, active: false });
      }

      var w = 0, h = 0, size = 40;
      function resize() {
        w = el.clientWidth; h = el.clientHeight;
        size = +d.size || Math.max(26, Math.min(72, Math.min(w, h) * 0.13));
        bats.forEach(function (b) {
          b.el.setAttribute('width', size);
          b.el.setAttribute('height', size / 2);
        });
      }
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      var right = from.indexOf('right') >= 0, bottom = from.indexOf('bottom') >= 0;

      // Send a bat off: from just outside its corner to a random point past
      // the far side.
      function launch(b, delay) {
        var sx = right ? w + size * 0.3 : -size * 0.3;
        var sy = bottom ? h + size * 0.2 : -size * 0.2;
        var ex = right ? -size * 1.6 : w + size * 1.6;
        var ey = h * (0.1 + Math.random() * 0.8);
        if (Math.random() < 0.3) { ex = w * (0.2 + Math.random() * 0.6); ey = bottom ? -size * 1.4 : h + size * 1.4; }
        b.sx = sx; b.sy = sy; b.ex = ex; b.ey = ey;
        b.t = -delay;
        b.dur = 2600 + Math.random() * 1600;
        b.wob = (Math.random() < 0.5 ? -1 : 1) * (0.05 + Math.random() * 0.08) * Math.min(w, h);
        b.waves = 1 + Math.random() * 1.5;
        b.flap = 7 + Math.random() * 4;
        b.ph = Math.random() * 6.28;
        b.grow = 0.9 + Math.random() * 0.5;
        b.active = true;
      }

      function wave() {
        bats.forEach(function (b, i) { launch(b, i * 170 + Math.random() * 120); });
      }

      function frame(dt) {
        var any = false;
        for (var i = 0; i < bats.length; i++) {
          var b = bats[i];
          if (!b.active) continue;
          any = true;
          b.t += dt;
          if (b.t < 0) continue;
          var u = b.t / b.dur;
          if (u >= 1) { b.active = false; b.el.style.visibility = 'hidden'; continue; }
          // Ease out of the corner, swoop along a wavy line.
          var e = Math.min(1, u * 0.35 + (1 - Math.pow(1 - u, 1.6)) * 0.65);
          var dx = b.ex - b.sx, dy = b.ey - b.sy, len = Math.sqrt(dx * dx + dy * dy) || 1;
          var off = Math.sin(e * Math.PI * 2 * b.waves + b.ph) * b.wob * Math.sin(e * Math.PI);
          var x = b.sx + dx * e - dy / len * off;
          var y = b.sy + dy * e + dx / len * off;
          // Small in the corner, larger as they come towards you.
          var sc = (0.35 + 0.75 * Math.min(1, e * 1.6)) * b.grow;
          var tilt = Math.cos(e * Math.PI * 2 * b.waves + b.ph) * 14 * (right ? -1 : 1);
          b.el.style.visibility = '';
          b.el.style.transform = 'translate(' + (x - size / 2).toFixed(1) + 'px,' + (y - size / 4).toFixed(1) + 'px) rotate(' + tilt.toFixed(1) + 'deg) scale(' + sc.toFixed(3) + ')';
          var k = Math.sin(b.t / 1000 * Math.PI * 2 * b.flap);
          b.wings.setAttribute('transform', 'scale(1 ' + (0.25 + 0.75 * k).toFixed(3) + ')');
        }
        return any;
      }

      if (reduced()) return;   // no bats at all: they only make sense in motion

      var gap = 0, visible = false, 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;
          if (!frame(dt)) {
            if (!loop) { timer.pause(); return; }
            gap += dt;
            if (gap > 1400) { gap = 0; wave(); }
          }
        }
      });

      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) {
          if (!started) { started = true; wave(); }
          if (loop || bats.some(function (b) { return b.active; })) { last = 0; timer.play(); }
        } else timer.pause();
      }, { threshold: 0.25 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-flying-bats]').forEach(function (el) {
        if (el._fbt) return;   // idempotent
        el._fbt = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-flying-bats>
  <h2>Happy Halloween</h2>
</section>

<!-- a steady stream from the bottom left: -->
<section data-flying-bats data-loop data-from="bottom-left" data-count="6">…</section>