Bubbles
Soap bubbles with a rainbow sheen rise up a section, wobbling from side to side and squashing gently as they go. Now and then one pops, and any bubble the pointer touches pops too.
Pop a bubble
How to use it
- Paste the snippet just before
</head>. - Add
data-bubblesto a section, hero or card. It looks best on a deep colour.
Options
Add any of these attributes to the same element.
data-density | Bubbles per 100 × 100 px. Default 0.5. |
data-size | Radius of the biggest bubbles in px. Default 26. |
data-speed | Speed multiplier. Default 1. |
data-color | Tint. Default white (clear). |
data-pop="false" | The pointer doesn’t pop them. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the bubbles hang still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bubbles: soap bubbles rise up a section, wobbling side to side and
// squashing gently as they go. Some pop on the way up, and the pointer pops
// any bubble it touches. Drawn on a transparent canvas behind your content,
// and paused when off screen.
// Usage: <section data-bubbles style="background:#0c4a6e">…</section>
// Optional: data-density (bubbles per 100 × 100 px, default 0.5),
// data-size (largest radius in px, default 26), data-speed (default 1),
// data-color (tint, default white), data-pop="false" (the pointer doesn't
// pop them).
var DEFAULTS = { density: 0.5, size: 26, speed: 1, color: '#ffffff' };
var css =
'[data-bubbles]{position:relative;isolation:isolate;overflow:hidden}' +
'.bbl-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 pre-drawn bubble: a clear middle, a bright rim with a hint of rainbow,
// and a window-shaped highlight.
function sprite(color) {
var S = 128, c = document.createElement('canvas');
c.width = c.height = S;
var g = c.getContext('2d'), r = S / 2 - 2;
g.translate(S / 2, S / 2);
var body = g.createRadialGradient(0, 0, r * 0.55, 0, 0, r);
body.addColorStop(0, 'rgba(255,255,255,0)');
body.addColorStop(0.75, 'rgba(255,255,255,.08)');
body.addColorStop(1, 'rgba(255,255,255,.55)');
g.fillStyle = body;
g.beginPath();
g.arc(0, 0, r, 0, Math.PI * 2);
g.fill();
// Rainbow sheen round the edge.
var sheen = ['rgba(244,114,182,.5)', 'rgba(250,204,21,.4)', 'rgba(74,222,128,.4)', 'rgba(56,189,248,.5)', 'rgba(167,139,250,.5)'];
g.lineWidth = r * 0.07;
sheen.forEach(function (s, i) {
g.strokeStyle = s;
g.beginPath();
g.arc(0, 0, r * 0.95, (i / sheen.length) * Math.PI * 2 + 0.6, ((i + 1) / sheen.length) * Math.PI * 2 + 0.6);
g.stroke();
});
g.strokeStyle = 'rgba(255,255,255,.7)';
g.lineWidth = 1.5;
g.beginPath();
g.arc(0, 0, r, 0, Math.PI * 2);
g.stroke();
// Highlights.
g.fillStyle = 'rgba(255,255,255,.85)';
g.beginPath();
g.ellipse(-r * 0.42, -r * 0.42, r * 0.2, r * 0.1, -Math.PI / 4, 0, Math.PI * 2);
g.fill();
g.fillStyle = 'rgba(255,255,255,.5)';
g.beginPath();
g.arc(r * 0.45, r * 0.5, r * 0.06, 0, Math.PI * 2);
g.fill();
// Tint the whole bubble.
if (color.toLowerCase() !== '#ffffff' && color.toLowerCase() !== '#fff' && color !== 'white') {
g.globalCompositeOperation = 'source-atop';
g.fillStyle = color;
g.globalAlpha = 0.45;
g.fillRect(-S / 2, -S / 2, S, S);
}
return c;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'bbl-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var density = +d.density || DEFAULTS.density;
var maxR = +d.size || DEFAULTS.size;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var color = d.color || DEFAULTS.color;
var canPop = d.pop !== 'false';
var img = sprite(color);
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, t = 0;
var bubbles = [], pops = [], pointer = null;
function bubble(b, below) {
b.r = maxR * Math.pow(Math.random(), 1.6) * 0.85 + maxR * 0.15;
b.x = Math.random() * w;
b.y = below ? h + b.r + Math.random() * h * 0.3 : Math.random() * h;
b.vy = (0.02 + (b.r / maxR) * 0.035) * R(0.8, 1.25, 2);
b.sway = R(6, 18);
b.freq = R(1.2, 2.4, 2) / 1000;
b.phase = Math.random() * Math.PI * 2;
// Some burst on their own somewhere up the section.
b.popAt = Math.random() < 0.35 ? R(0.1, 0.7, 2) * h : -Infinity;
return b;
}
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
var n = Math.round(Math.min(160, Math.max(10, (w * h * density) / 10000)));
while (bubbles.length < n) bubbles.push(bubble({}, false));
bubbles.length = n;
}
function pop(b, x) {
pops.push({ x: x, y: b.y, r: b.r, age: 0 });
bubble(b, true);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
for (var i = 0; i < bubbles.length; i++) {
var b = bubbles[i];
var s = Math.sin(t * b.freq + b.phase);
var x = b.x + s * b.sway;
// Squash and stretch as it wobbles.
var sx = 1 + Math.cos(t * b.freq * 2 + b.phase) * 0.05, sy = 2 - sx;
ctx.drawImage(img, x - b.r * sx, b.y - b.r * sy, b.r * 2 * sx, b.r * 2 * sy);
}
// A pop: a ring flicks outwards with a few droplets, and fades.
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 1.5;
for (i = 0; i < pops.length; i++) {
var p = pops[i], k = p.age / 320;
ctx.globalAlpha = (1 - k) * 0.8;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * (1 + k * 0.6), 0, Math.PI * 2);
ctx.setLineDash([p.r * 0.35, p.r * 0.3]);
ctx.stroke();
ctx.setLineDash([]);
for (var j = 0; j < 6; j++) {
var a = (j / 6) * Math.PI * 2 + p.r;
ctx.beginPath();
ctx.arc(p.x + Math.cos(a) * p.r * (1 + k * 1.1), p.y + Math.sin(a) * p.r * (1 + k * 1.1), 1.6 * (1 - k) + 0.4, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
function advance(dt) {
t += dt;
var unit = Math.max(0.6, h / 500);
for (var i = 0; i < bubbles.length; i++) {
var b = bubbles[i];
b.y -= b.vy * dt * speed * unit;
var x = b.x + Math.sin(t * b.freq + b.phase) * b.sway;
if (b.y < b.popAt) { pop(b, x); continue; }
if (pointer) {
var dx = x - pointer.x, dy = b.y - pointer.y;
if (dx * dx + dy * dy < b.r * b.r) { pop(b, x); continue; }
}
if (b.y < -b.r) bubble(b, true);
}
pops = pops.filter(function (p) { p.age += dt; return p.age < 320; });
}
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);
if (canPop) {
el.addEventListener('pointermove', function (e) {
var r = el.getBoundingClientRect();
pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
});
el.addEventListener('pointerleave', function () { pointer = null; });
}
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();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-bubbles]').forEach(function (el) {
if (el._bbl) return;
el._bbl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-bubbles style="background: #0c4a6e; color: #fff">
<h1>Pop a bubble</h1>
</section>