← Particles

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

  1. Paste the snippet just before </head>.
  2. Add data-bubbles to a section, hero or card. It looks best on a deep colour.

Options

Add any of these attributes to the same element.

data-densityBubbles per 100 × 100 px. Default 0.5.
data-sizeRadius of the biggest bubbles in px. Default 26.
data-speedSpeed multiplier. Default 1.
data-colorTint. 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.

1 · Snippet
<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>
2 · Your HTML
<section data-bubbles style="background: #0c4a6e; color: #fff">
  <h1>Pop a bubble</h1>
</section>