Bubble Wrap
A sheet of plump plastic bubbles, made for popping. Each one squeezes, pops with a little puff and stays crinkled flat. Drag the mouse across to pop a row in one go. Pop the lot and a fresh sheet inflates.
How to use it
- Paste the snippet just before
</head>. - Add
data-bubble-wrapto an empty element. It fills it with as many bubbles as fit across.
Options
Add any of these attributes to the same element.
data-rows | Rows of bubbles. Default 4. |
data-size | Bubble size in px. Default 44, a little smaller on narrow screens. |
data-autoplay | Pop bubbles by itself while in view. It pauses for a few seconds whenever someone pops one. |
data-refill="false" | Don’t inflate a fresh sheet once they’re all popped. |
Every bubble is a real button, so they can be popped from the keyboard too, and screen readers hear whether each is popped. The bubbles are see-through, so put the sheet on a colour or picture. With reduced motion they just flatten. From code, animacious.bubbleWrapReset(element) inflates a fresh sheet.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bubble Wrap: fills an element with a sheet of plump plastic bubbles you
// can pop one by one. Each pops with a squeeze, a little burst and a
// crinkle, and stays flat. Drag the mouse across to pop a row in one go.
// Pop the lot and a fresh sheet inflates.
// Usage: <div data-bubble-wrap></div>
// Optional: data-rows (rows of bubbles, default 4), data-size (bubble size
// in px, default 44; fewer columns fit on narrow screens), data-autoplay
// (pops bubbles by itself while in view, pausing a while whenever someone
// pops one), data-refill="false" (don't inflate a fresh sheet when they're
// all popped).
// Script use: animacious.bubbleWrapReset(element) inflates a fresh sheet.
var DEFAULTS = { rows: 4, size: 44 };
var css =
'[data-bubble-wrap]{display:flex;flex-direction:column;align-items:center;gap:calc(var(--bbw-s) * .14);user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}' +
'.bbw-row{display:flex;gap:calc(var(--bbw-s) * .14)}' +
'.bbw-row:nth-child(odd){transform:translateX(calc(var(--bbw-s) * -.285))}' +
'.bbw-row:nth-child(even){transform:translateX(calc(var(--bbw-s) * .285))}' +
'.bbw-b{position:relative;display:block;width:var(--bbw-s);height:var(--bbw-s);padding:0;border:0;border-radius:50%;cursor:pointer;font:inherit;color:inherit;' +
'background:radial-gradient(circle at 34% 30%,rgba(255,255,255,.95) 0,rgba(255,255,255,.55) 14%,rgba(255,255,255,.14) 42%,rgba(255,255,255,.05) 62%,rgba(255,255,255,.35) 100%);' +
'box-shadow:inset 0 0 0 1px rgba(255,255,255,.55),inset -3px -5px 8px rgba(0,0,0,.08),0 3px 6px rgba(0,0,0,.12);transition:transform .15s}' +
'.bbw-b:focus-visible{outline:2px solid currentColor;outline-offset:2px}' +
'@media (hover:hover) and (pointer:fine){.bbw-b:not(.bbw-popped):hover{transform:scale(1.05)}}' +
'.bbw-b.bbw-popped{cursor:default;background:radial-gradient(circle at 50% 50%,rgba(255,255,255,.08) 0,rgba(255,255,255,.18) 60%,rgba(255,255,255,.3) 100%);' +
'box-shadow:inset 0 0 0 1px rgba(255,255,255,.35),inset 0 2px 3px rgba(0,0,0,.08)}' +
'.bbw-b.bbw-popped::before,.bbw-b.bbw-popped::after{content:"";position:absolute;left:24%;right:30%;top:40%;height:1.5px;border-radius:2px;background:rgba(255,255,255,.5);transform:rotate(-14deg)}' +
'.bbw-b.bbw-popped::after{left:34%;right:22%;top:60%;transform:rotate(-6deg)}' +
'.bbw-bit{position:absolute;left:50%;top:50%;width:4px;height:4px;margin:-2px;border-radius:50%;background:#fff;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function build(el) {
var d = el.dataset;
var rows = +d.rows || DEFAULTS.rows;
var baseSize = +d.size || DEFAULTS.size;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var refill = d.refill !== 'false';
var inThumb = !!el.closest('[aria-hidden="true"]');
var cols = 0, size = baseSize, bubbles = [], dragging = false, quietUntil = 0, refilling = false;
if (!inThumb && !el.hasAttribute('role')) { el.setAttribute('role', 'group'); el.setAttribute('aria-label', 'Bubble wrap'); }
function layout() {
var cs = getComputedStyle(el);
var w = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
if (!w) return;
// Fit as many columns as the width allows (shrinking bubbles a little
// on narrow screens rather than showing only a few).
size = Math.min(baseSize, Math.max(28, w / 7.6));
var c = Math.max(3, Math.floor((w - size * 0.6) / (size * 1.14)));
if (c === cols) return;
cols = c;
el.style.setProperty('--bbw-s', size.toFixed(1) + 'px');
el.innerHTML = '';
bubbles = [];
for (var r = 0; r < rows; r++) {
var row = document.createElement('div');
row.className = 'bbw-row';
for (var i = 0; i < cols; i++) {
var b = document.createElement(inThumb ? 'span' : 'button');
b.className = 'bbw-b';
if (!inThumb) { b.type = 'button'; b.setAttribute('aria-label', 'Bubble'); b.setAttribute('aria-pressed', 'false'); }
row.appendChild(b);
bubbles.push(b);
}
el.appendChild(row);
}
}
function pop(b, byUser) {
if (!b || b._bbwPopped || refilling) return;
b._bbwPopped = true;
if (byUser) quietUntil = performance.now() + 4000;
if (!inThumb) { b.setAttribute('aria-pressed', 'true'); b.setAttribute('aria-label', 'Popped bubble'); }
if (reduced()) { b.classList.add('bbw-popped'); }
else {
// Squeeze, then snap flat with a puff of tiny bits.
anime.createTimeline()
.add(b, { scaleX: 1.16, scaleY: 0.86, duration: 90, ease: 'out(2)' })
.call(function () { b.classList.add('bbw-popped'); burst(b); })
.add(b, { scaleX: [0.84, 1], scaleY: [1.1, 0.94], duration: 420, ease: 'outElastic(1, .5)' });
}
if (refill && bubbles.every(function (x) { return x._bbwPopped; })) setTimeout(reset, 1100);
}
function burst(b) {
for (var i = 0; i < 6; i++) {
var bit = document.createElement('span');
bit.className = 'bbw-bit';
bit.setAttribute('aria-hidden', 'true');
b.appendChild(bit);
var a = (i / 6) * Math.PI * 2 + Math.random() * 0.5, dist = size * (0.5 + Math.random() * 0.25);
anime.animate(bit, {
x: Math.cos(a) * dist, y: Math.sin(a) * dist, scale: [1, 0.2], opacity: [0.9, 0],
duration: 380, ease: 'out(3)',
onComplete: bit.remove.bind(bit)
});
}
}
function reset() {
refilling = true;
var fresh = bubbles.slice();
fresh.forEach(function (b) {
b._bbwPopped = false;
b.classList.remove('bbw-popped');
if (!inThumb) { b.setAttribute('aria-pressed', 'false'); b.setAttribute('aria-label', 'Bubble'); }
});
if (reduced()) { refilling = false; return; }
anime.animate(fresh, {
scale: [0.4, 1], opacity: [0, 1], duration: 520, ease: 'outBack(2)',
delay: anime.stagger(22),
onComplete: function () { refilling = false; }
});
}
el._bbwReset = reset;
layout();
if (window.ResizeObserver) new ResizeObserver(layout).observe(el);
el.addEventListener('click', function (e) {
var b = e.target.closest('.bbw-b');
if (b && el.contains(b)) pop(b, true);
});
// Drag the mouse across bubbles to pop them as you go.
el.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse') dragging = true; });
window.addEventListener('pointerup', function () { dragging = false; });
el.addEventListener('pointerover', function (e) {
if (!dragging) return;
var b = e.target.closest('.bbw-b');
if (b && el.contains(b)) pop(b, true);
});
if (autoplay && !reduced()) {
var timer = null;
var tick = function () {
if (performance.now() < quietUntil || refilling) return;
var left = bubbles.filter(function (b) { return !b._bbwPopped; });
if (left.length) pop(left[(Math.random() * left.length) | 0], false);
};
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { if (!timer) timer = setInterval(tick, 380); }
else if (timer) { clearInterval(timer); timer = null; }
}).observe(el);
}
}
window.animacious = window.animacious || {};
window.animacious.bubbleWrapReset = function (el) { if (el && el._bbwReset) el._bbwReset(); };
function init() {
document.querySelectorAll('[data-bubble-wrap]').forEach(function (el) {
if (el._bbw) return;
el._bbw = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-bubble-wrap></div>
<!-- three rows of smaller bubbles: -->
<div data-bubble-wrap data-rows="3" data-size="36"></div>