Balloons
Shiny party balloons float up a section, swaying from side to side on their strings and tilting into each swing. Near ones are bigger and quicker, far ones small and faint. Tap anywhere inside to let another one go.
Tap to let one go
How to use it
- Paste the snippet just before
</head>. - Add
data-balloonsto a section, hero or card. Every tap or click lets another balloon go from that spot.
Options
Add any of these attributes to the same element.
data-density | Balloons per 100 × 100 px. Default 0.5. |
data-size | Width of the nearest balloons, in px. Default 54. |
data-speed | Speed multiplier. Default 1. |
data-colors | Comma-separated balloon colours. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the balloons hang still. From code, animacious.balloon(element, x, y) lets one go.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Balloons: shiny party balloons float up through a section, swaying from
// side to side on their strings and tilting into each swing. Tap or click
// anywhere inside to let another one go from that spot. Drawn on a
// transparent canvas behind your content, and paused when off screen.
// Usage: <section data-balloons>…</section>
// Optional: data-density (balloons per 100 × 100 px, default 0.5),
// data-size (width of the nearest balloons in px, default 54),
// data-speed (speed multiplier, default 1),
// data-colors="#ef4444,#f59e0b,…" (balloon colours).
// Script use: animacious.balloon(element, x, y) lets one go from code.
var DEFAULTS = {
density: 0.5, size: 54, speed: 1,
colors: ['#ef4444', '#f59e0b', '#facc15', '#22c55e', '#38bdf8', '#a855f7', '#ec4899']
};
var css =
'[data-balloons]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
'.bln-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// A balloon 60 px wide on a 64 × 84 box: a slightly egg-shaped body with a
// soft shine, a darker rim and a little knot at the bottom.
function sprite(color) {
var c = document.createElement('canvas');
c.width = 64;
c.height = 84;
var g = c.getContext('2d');
g.fillStyle = color;
g.beginPath();
g.moveTo(32, 74);
g.bezierCurveTo(14, 66, 2, 50, 2, 32);
g.bezierCurveTo(2, 14, 15, 2, 32, 2);
g.bezierCurveTo(49, 2, 62, 14, 62, 32);
g.bezierCurveTo(62, 50, 50, 66, 32, 74);
g.fill();
// The knot.
g.beginPath();
g.moveTo(32, 72);
g.lineTo(27, 80);
g.lineTo(37, 80);
g.closePath();
g.fill();
// Shading: light from the top left, darker towards the rim.
g.globalCompositeOperation = 'source-atop';
var shade = g.createRadialGradient(22, 22, 2, 30, 34, 44);
shade.addColorStop(0, 'rgba(255,255,255,.5)');
shade.addColorStop(0.35, 'rgba(255,255,255,0)');
shade.addColorStop(0.8, 'rgba(0,0,0,.12)');
shade.addColorStop(1, 'rgba(0,0,0,.3)');
g.fillStyle = shade;
g.fillRect(0, 0, 64, 84);
g.globalCompositeOperation = 'source-over';
// A glossy highlight.
g.fillStyle = 'rgba(255,255,255,.7)';
g.beginPath();
g.ellipse(19, 20, 6, 10, 0.5, 0, Math.PI * 2);
g.fill();
g.fillStyle = 'rgba(255,255,255,.45)';
g.beginPath();
g.arc(17, 36, 2.4, 0, Math.PI * 2);
g.fill();
return c;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'bln-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var density = d.density !== undefined ? +d.density : DEFAULTS.density;
var size = +d.size || DEFAULTS.size;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var imgs = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(function (c) { return sprite(c.trim()); });
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, t = 0;
var balloons = [], extra = [];
function balloon(x, y) {
var depth = R(0.55, 1, 2);
var s = size * depth;
return {
x: x === undefined ? Math.random() * w : x,
y: y === undefined ? h + s * 1.5 + Math.random() * h * 0.4 : y,
s: s,
depth: depth,
img: imgs[Math.floor(Math.random() * imgs.length)],
v: R(0.028, 0.05, 3) * (0.6 + depth * 0.5), // px per ms, near ones a little faster
amp: R(6, 16) * depth,
period: R(2600, 4400),
phase: Math.random() * Math.PI * 2,
drift: R(-0.004, 0.004, 4),
str: R(1.3, 1.8, 2) // string length, in balloon widths
};
}
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
var first = !w || !h, ow = w, oh = h;
w = el.clientWidth;
h = el.clientHeight;
if (!w || !h) return;
// Keep the balloons in the same places, relative to the new size.
if (!first) balloons.concat(extra).forEach(function (b) { b.x *= w / ow; b.y *= h / oh; });
canvas.width = w * dpr;
canvas.height = h * dpr;
var n = Math.round(Math.min(60, Math.max(5, (w * h) / 10000 * density)));
while (balloons.length < n) {
var b = balloon();
// On the first fill, spread them over the whole box so it isn't empty.
if (first) b.y = R(-0.1, 1.15, 2) * h;
balloons.push(b);
}
balloons.length = n;
balloons.sort(function (a, b) { return a.depth - b.depth; });
}
function drawOne(b) {
var ph = (t / b.period) * Math.PI * 2 + b.phase;
var sway = Math.sin(ph) * b.amp;
var tilt = Math.cos(ph) * 0.13;
var bw = b.s, bh = b.s * 84 / 64;
var x = b.x + sway, y = b.y;
// The string hangs from the knot, its tail lagging behind the swing.
var kx = x - Math.sin(tilt) * bh * 0.42, ky = y + Math.cos(tilt) * bh * 0.42;
var len = b.s * b.str;
var lag = Math.sin(ph - 1.1) * b.amp * 0.9;
ctx.globalAlpha = 0.45 + b.depth * 0.55;
ctx.strokeStyle = 'rgba(80,80,90,.55)';
ctx.lineWidth = Math.max(0.8, b.depth * 1.2);
ctx.beginPath();
ctx.moveTo(kx, ky);
ctx.bezierCurveTo(kx + lag * 0.6, ky + len * 0.35, kx - lag * 0.8, ky + len * 0.65, kx - lag * 0.3, ky + len);
ctx.stroke();
ctx.save();
ctx.translate(x, y);
ctx.rotate(tilt);
ctx.drawImage(b.img, -bw / 2, -bh / 2, bw, bh);
ctx.restore();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
for (var i = 0; i < balloons.length; i++) drawOne(balloons[i]);
for (var j = 0; j < extra.length; j++) drawOne(extra[j]);
ctx.globalAlpha = 1;
}
function step(list, dt, recycle) {
for (var i = list.length - 1; i >= 0; i--) {
var b = list[i];
b.y -= b.v * dt * speed;
b.x += b.drift * dt * speed;
if (b.y + b.s * (b.str + 1) < 0) {
if (!recycle) { list.splice(i, 1); continue; }
var n = balloon();
n.depth = b.depth;
n.s = b.s;
list[i] = n;
}
}
}
function advance(dt) {
t += dt;
step(balloons, dt, true);
step(extra, dt, false);
}
function release(x, y) {
if (extra.length > 30) extra.shift();
var b = balloon(x, y);
b.depth = 1;
b.s = size;
b.v *= 1.3;
extra.push(b);
anime.animate(b, { s: [size * 0.2, size], duration: 500, ease: 'outElastic(1, .6)' });
}
resize();
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
el.addEventListener('pointerdown', function (e) {
var r = el.getBoundingClientRect();
release(e.clientX - r.left, e.clientY - r.top);
});
el._blnRelease = release;
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();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-balloons]').forEach(function (el) {
if (el._bln) return;
el._bln = true;
build(el);
});
}
window.animacious = window.animacious || {};
window.animacious.balloon = function (el, x, y) {
if (el && el._blnRelease) el._blnRelease(x === undefined ? el.clientWidth / 2 : x, y === undefined ? el.clientHeight : y);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-balloons>
<h1>Happy birthday!</h1>
</section>