← Particles

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

  1. Paste the snippet just before </head>.
  2. Add data-bubble-wrap to an empty element. It fills it with as many bubbles as fit across.

Options

Add any of these attributes to the same element.

data-rowsRows of bubbles. Default 4.
data-sizeBubble size in px. Default 44, a little smaller on narrow screens.
data-autoplayPop 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.

1 · Snippet
<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>
2 · Your HTML
<div data-bubble-wrap></div>

<!-- three rows of smaller bubbles: -->
<div data-bubble-wrap data-rows="3" data-size="36"></div>