Hot Air Balloons
Striped hot air balloons rise slowly through a morning sky, swaying as they go and firing their burners now and then. Clouds slide down past them, some behind and some in front, and the far balloons are smaller, paler and slower.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-hot-air-balloons>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-count | Number of balloons. Default 6. |
data-colors | Comma-separated balloon colours, used in turn. Default violet, amber, pink, sky blue, green and red. |
data-speed | Speed multiplier. Default 1. |
It is 8:5 with a sky background by default; set your own height or background on the element to change that. The balloons use the whole width of a wide banner. It pauses when off screen. With reduced motion it’s a still picture.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Hot Air Balloons: striped balloons rise slowly through a morning sky,
// swaying as they go and firing their burners now and then. Clouds slide
// past behind and in front of them, and far balloons are smaller, paler
// and slower. Drawn as SVG that fills the element, and paused when off
// screen.
// Usage: <div data-hot-air-balloons></div>
// It is 8:5 by default with a sky background; give it your own height or
// background to change that.
// Optional: data-count (number of balloons, default 6), data-colors
// (comma-separated balloon colours), data-speed (default 1).
var DEFAULTS = { count: 6, speed: 1, colors: ['#7c3aed', '#f59e0b', '#ec4899', '#0ea5e9', '#22c55e', '#ef4444'] };
var NS = 'http://www.w3.org/2000/svg';
var css =
'[data-hot-air-balloons]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
'background:linear-gradient(#7dd3fc,#bae6fd 55%,#fef3c7)}' +
'.hab-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rand(a, b) { return a + Math.random() * (b - a); }
var ENVELOPE = 'M0 -40C26 -40 34 -18 30 -2C26 14 10 24 6 32L-6 32C-10 24 -26 14 -30 -2C-34 -18 -26 -40 0 -40Z';
var GORE = 'M0 -40C11 -40 15 -16 12 0C10 14 6 24 3 32L-3 32C-6 24 -10 14 -12 0C-15 -16 -11 -40 0 -40Z';
var SHADE = 'M0 -40C26 -40 34 -18 30 -2C26 14 10 24 6 32L3 32C14 18 24 0 20 -16C17 -30 10 -40 0 -40Z';
function balloonHTML(col) {
return '<path d="M-6 34L-5 43M6 34L5 43" stroke="#78350f" stroke-width="1"/>' +
'<ellipse class="hab-flame" cx="0" cy="38" rx="2.4" ry="4" fill="#fb923c" opacity="0"/>' +
'<path d="' + ENVELOPE + '" fill="' + col + '"/>' +
'<path d="' + GORE + '" fill="#fff" opacity=".82"/>' +
'<path d="M0 -40C5 -40 6 -16 5 0C4 14 2 24 1 32L-1 32C-2 24 -4 14 -5 0C-6 -16 -5 -40 0 -40Z" fill="' + col + '"/>' +
'<path d="' + SHADE + '" fill="#000" opacity=".13"/>' +
'<ellipse cx="-17" cy="-20" rx="3.5" ry="9" transform="rotate(18 -17 -20)" fill="#fff" opacity=".35"/>' +
'<rect x="-6.5" y="31" width="13" height="4" rx="1.5" fill="#1e1b2e" opacity=".55"/>' +
'<rect x="-6" y="42" width="12" height="9" rx="1.5" fill="#a16207"/>' +
'<rect x="-6.5" y="41.5" width="13" height="2.5" rx="1" fill="#78350f"/>';
}
function cloudHTML(w) {
return '<rect x="' + (-w / 2) + '" y="-8" width="' + w + '" height="16" rx="8"/>' +
'<circle cx="' + (-w * 0.2) + '" cy="-9" r="' + (w * 0.19) + '"/><circle cx="' + (w * 0.1) + '" cy="-13" r="' + (w * 0.25) + '"/>';
}
function build(el) {
var d = el.dataset;
var count = d.count !== undefined ? Math.max(1, Math.min(14, Math.round(+d.count))) : DEFAULTS.count;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Hot air balloons drifting up past the clouds');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
el.innerHTML = '<svg class="hab-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<g class="hab-back" fill="#fff" opacity=".7"></g><g class="hab-balloons"></g><g class="hab-front" fill="#fff" opacity=".88"></g></svg>';
var svg = el.firstChild;
// The part of the viewBox that is actually on screen, so balloons and
// clouds use the whole width of a wide banner or a tall phone.
var view = { x0: 0, x1: 400, y0: 0, y1: 250 };
function measure() {
var w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return;
var r = w / h, W = Math.max(400, 250 * r), H = Math.max(250, 400 / r);
view = { x0: 200 - W / 2, x1: 200 + W / 2, y0: 125 - H / 2, y1: 125 + H / 2 };
}
measure();
var balloons = [];
var slots = [];
for (var i = 0; i < count; i++) slots.push((i + 0.5) / count);
slots.sort(function () { return Math.random() - 0.5; });
for (var k = 0; k < count; k++) {
var s = 0.42 + (k / Math.max(1, count - 1)) * 0.62; // back to front
var g = document.createElementNS(NS, 'g');
g.innerHTML = balloonHTML(colors[k % colors.length]);
g.setAttribute('opacity', (0.62 + 0.38 * (s - 0.42) / 0.62).toFixed(2));
svg.querySelector('.hab-balloons').appendChild(g);
balloons.push({
el: g, flame: g.querySelector('.hab-flame'), s: s,
fx: slots[k], y: view.y0 + (view.y1 - view.y0) * (0.18 + 0.78 * ((k * 0.618 + 0.3) % 1)),
v: 0.007 + 0.013 * s, ph: rand(0, 6.28), burn: rand(0, 4000)
});
}
var clouds = [];
function addClouds(layer, n, w0, w1, v) {
var g = svg.querySelector(layer);
for (var i = 0; i < n; i++) {
var c = document.createElementNS(NS, 'g');
var w = rand(w0, w1);
c.innerHTML = cloudHTML(w);
g.appendChild(c);
clouds.push({ el: c, fx: (i + rand(0.1, 0.9)) / n, y: view.y0 + (view.y1 - view.y0) * ((i + 0.5) / n), v: v * rand(0.8, 1.2), w: w, dx: rand(-0.004, 0.004) });
}
}
addClouds('.hab-back', 5, 40, 70, 0.005);
addClouds('.hab-front', 2, 90, 130, 0.016);
function frame(dt, t) {
var W = view.x1 - view.x0;
balloons.forEach(function (b) {
b.y -= b.v * dt;
if (b.y < view.y0 - 56 * b.s) { b.y = view.y1 + 44 * b.s; b.fx = rand(0.08, 0.92); }
var x = view.x0 + 30 + (W - 60) * b.fx + Math.sin(t * 0.0006 + b.ph) * 10 * b.s;
var rot = Math.sin(t * 0.0006 + b.ph + 0.8) * 3;
b.el.setAttribute('transform', 'translate(' + x.toFixed(1) + ' ' + b.y.toFixed(1) + ') rotate(' + rot.toFixed(2) + ') scale(' + b.s.toFixed(3) + ')');
// The burner fires for a moment every few seconds.
b.burn -= dt;
if (b.burn < -500) b.burn = rand(2500, 6000);
var on = b.burn < 0 ? 0.7 + Math.random() * 0.3 : 0;
b.flame.setAttribute('opacity', on.toFixed(2));
});
clouds.forEach(function (c) {
// Clouds sink, so the balloons seem to rise past them.
c.y += c.v * dt;
c.fx += c.dx * dt / W;
if (c.y > view.y1 + 40) { c.y = view.y0 - 40; c.fx = rand(0, 1); }
var x = view.x0 + W * (((c.fx % 1) + 1) % 1);
c.el.setAttribute('transform', 'translate(' + x.toFixed(1) + ' ' + c.y.toFixed(1) + ')');
});
}
if (reduced()) { frame(0, 0); return; }
var t = 0, 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) * speed;
last = now;
t += dt;
frame(dt, t);
}
});
frame(0, 0);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
if (window.ResizeObserver) new ResizeObserver(measure).observe(el);
}
function init() {
document.querySelectorAll('[data-hot-air-balloons]').forEach(function (el) {
if (el._hab) return; // idempotent
el._hab = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-hot-air-balloons></div>
<!-- fewer balloons, in your own colours: -->
<div data-hot-air-balloons data-count="4" data-colors="#0ea5e9,#f43f5e"></div>