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
- Paste the snippet just before
</head>. - Add
data-flying-batsto any section. The bats fly out when it scrolls into view.
Options
Add any of these attributes to the same element.
data-loop | Send another flight every few seconds. |
data-from | The corner they come from: top-right, top-left, bottom-left or bottom-right. Default top-right. |
data-count | Bats per flight. Default 9. |
data-color | Bat colour. Default near-black #120b1f. |
data-size | Wingspan 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.
<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><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>