← Seasonal

Champagne Pop

A bottle of bubbly trembles as the cork wriggles free. Then it shoots off with a flash and a pop, foam gushes out of the neck and a stream of golden bubbles fizzes up into the air.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-champagne-pop> where the bottle should stand. The cork pops when it scrolls into view.

Options

Add any of these attributes to the same element.

data-loopPut a new cork in and pop it again.
data-colorColour of the glass. Default bottle green #166534.
data-bubblesHow fizzy, as a multiplier. Default 1.
data-delayHow long before the cork starts to wriggle. Default 500 ms.

It is 8:5 with a transparent background by default; set your own height or background on the element. animacious.champagnePop(el) pops it again from script. It pauses when off screen. With reduced motion the cork is out and a few bubbles hang in the air.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Champagne Pop: a bottle of bubbly trembles, the cork wriggles free and
    // shoots off with a pop, foam gushes out of the neck and a stream of
    // bubbles fizzes up into the air. Drawn as SVG that fills the element, and
    // paused when off screen.
    // Usage: <div data-champagne-pop></div>
    // It is 8:5 by default with a transparent background; it looks best on
    // something dark.
    // Optional: data-loop (put a new cork in and pop again), data-color (glass
    // colour, default #166534), data-bubbles (how fizzy, a multiplier,
    // default 1), data-delay (ms before the cork starts to wriggle, default
    // 500).
    // Script use: animacious.champagnePop(el) pops it again.
    var DEFAULTS = { color: '#166534', bubbles: 1, delay: 500 };
    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;
    var BX = 156, BY = 232, TILT = 22, SC = 0.84;   // bottle base, lean in degrees, size
    var RAD = TILT * Math.PI / 180;
    var DIR = { x: Math.sin(RAD), y: -Math.cos(RAD) };   // along the neck
    var MOUTH = { x: BX + DIR.x * 150 * SC, y: BY + DIR.y * 150 * SC };

    var css =
      '[data-champagne-pop]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.chp-svg{position:absolute;inset:0;width:100%;height:100%;display:block}';
    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 n = ++uid;
      var g = 'chp' + n;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var color = d.color || DEFAULTS.color;
      var fizziness = Math.max(0, d.bubbles !== undefined ? +d.bubbles : DEFAULTS.bubbles);
      var wait = d.delay !== undefined ? +d.delay : DEFAULTS.delay;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A champagne cork pops and bubbles fizz up');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var rays = '';
      for (var k = 0; k < 9; k++) rays += '<path class="chp-ray" data-a="' + (-80 + k * 20) + '" stroke="#fde68a" stroke-width="2.4" stroke-linecap="round"/>';

      el.innerHTML =
        '<svg class="chp-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<linearGradient id="' + g + '-glass" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#052e16"/><stop offset=".35" stop-color="' + color + '"/><stop offset=".55" stop-color="' + color + '"/><stop offset="1" stop-color="#022c22"/></linearGradient>' +
        '<linearGradient id="' + g + '-foil" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#a16207"/><stop offset=".4" stop-color="#fde68a"/><stop offset="1" stop-color="#854d0e"/></linearGradient>' +
        '<linearGradient id="' + g + '-cork" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#92400e"/><stop offset=".45" stop-color="#d6a46a"/><stop offset="1" stop-color="#78350f"/></linearGradient>' +
        '<radialGradient id="' + g + '-burst"><stop offset="0" stop-color="#fffbeb" stop-opacity=".9"/><stop offset="1" stop-color="#fde68a" stop-opacity="0"/></radialGradient>' +
        '</defs>' +
        '<ellipse cx="' + (BX + 6) + '" cy="' + (BY + 2) + '" rx="40" ry="5" fill="#000" opacity=".35"/>' +
        '<circle class="chp-flash" cx="' + MOUTH.x.toFixed(1) + '" cy="' + MOUTH.y.toFixed(1) + '" r="40" fill="url(#' + g + '-burst)" opacity="0"/>' +
        '<g class="chp-rays" opacity="0">' + rays + '</g>' +
        '<g class="chp-parts"></g>' +
        '<g class="chp-bottle">' +
          // Body, shoulders and neck.
          '<path d="M-30 -4 Q-30 0 -26 0 L26 0 Q30 0 30 -4 L30 -86 C30 -104 10 -108 10 -124 L10 -150 L-10 -150 L-10 -124 C-10 -108 -30 -104 -30 -86 Z" fill="url(#' + g + '-glass)"/>' +
          '<path d="M-21 -10 L-21 -84 C-21 -96 -11 -102 -7 -112" fill="none" stroke="#fff" stroke-opacity=".28" stroke-width="4" stroke-linecap="round"/>' +
          // Label.
          '<rect x="-26" y="-72" width="52" height="38" rx="3" fill="#fef3c7"/>' +
          '<rect x="-26" y="-72" width="52" height="5" fill="#ca8a04"/><rect x="-26" y="-39" width="52" height="5" fill="#ca8a04"/>' +
          '<path d="M0 -61 L2.4 -55.6 L8 -55 L3.8 -51.2 L5 -45.6 L0 -48.4 L-5 -45.6 L-3.8 -51.2 L-8 -55 L-2.4 -55.6 Z" fill="#ca8a04"/>' +
          // Foil round the neck.
          '<path d="M-10 -150 L10 -150 L10 -122 C10 -116 14 -112 16 -108 L-16 -108 C-14 -112 -10 -116 -10 -122 Z" fill="url(#' + g + '-foil)"/>' +
          '<path d="M-14 -112 L14 -112" stroke="#713f12" stroke-opacity=".5" stroke-width="1.4"/>' +
          // The cork with its wire cage, sitting in the mouth.
          '<g class="chp-cork">' +
            '<path d="M-8 -150 L-8 -160 L8 -160 L8 -150 Z" fill="url(#' + g + '-cork)"/>' +
            '<path d="M-12 -160 Q-12 -174 0 -174 Q12 -174 12 -160 Z" fill="url(#' + g + '-cork)"/>' +
            '<path d="M-10 -150 L-6 -164 M10 -150 L6 -164 M-9 -156 L9 -156" stroke="#d4d4d8" stroke-width="1.1" fill="none"/>' +
          '</g>' +
        '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var bottle = svg.querySelector('.chp-bottle');
      var cork = svg.querySelector('.chp-cork');
      var flash = svg.querySelector('.chp-flash');
      var raysG = svg.querySelector('.chp-rays');
      var rayEls = svg.querySelectorAll('.chp-ray');
      var partsG = svg.querySelector('.chp-parts');

      var S;
      function reset() { S = { wig: 0, lift: 0, fly: 0, spin: 0, cork: 1, shake: 0, burst: 0, foam: 0, fizz: 0 }; }
      reset();

      function render() {
        var sh = S.shake ? Math.sin(performance.now() / 22) * S.shake : 0;
        bottle.setAttribute('transform', 'translate(' + (BX + sh).toFixed(2) + ' ' + BY + ') rotate(' + (TILT + sh * 0.6).toFixed(2) + ') scale(' + SC + ')');
        cork.setAttribute('transform', 'translate(0 ' + (-S.lift - S.fly).toFixed(1) + ') rotate(' + (S.wig + S.spin).toFixed(1) + ' 0 -160) scale(' + S.cork.toFixed(3) + ')');
        cork.setAttribute('opacity', S.cork > 0.01 ? 1 : 0);
        var b = S.burst;
        flash.setAttribute('opacity', b > 0 && b < 1 ? ((1 - b) * 0.9).toFixed(2) : 0);
        flash.setAttribute('r', (20 + b * 50).toFixed(1));
        raysG.setAttribute('opacity', b > 0 && b < 1 ? (1 - b).toFixed(2) : 0);
        rayEls.forEach(function (p) {
          var a = (+p.getAttribute('data-a') + TILT - 90) * Math.PI / 180;
          var r1 = 16 + b * 46, r2 = r1 + 14 * (1 - b) + 2;
          p.setAttribute('d', 'M' + (MOUTH.x + Math.cos(a) * r1).toFixed(1) + ' ' + (MOUTH.y + Math.sin(a) * r1).toFixed(1) +
            ' L' + (MOUTH.x + Math.cos(a) * r2).toFixed(1) + ' ' + (MOUTH.y + Math.sin(a) * r2).toFixed(1));
        });
      }

      // Foam and bubbles: a small pool of circles, recycled.
      var parts = [], pool = [];
      function spawn(kind) {
        var c = pool.pop();
        if (!c) { c = document.createElementNS(NS, 'circle'); partsG.appendChild(c); }
        var p = { el: c, kind: kind, age: 0 };
        if (kind === 'foam') {
          var spread = (Math.random() - 0.5) * 0.7;
          var speed = 120 + Math.random() * 160;
          var ax = DIR.x * Math.cos(spread) - DIR.y * Math.sin(spread);
          var ay = DIR.x * Math.sin(spread) + DIR.y * Math.cos(spread);
          p.x = MOUTH.x; p.y = MOUTH.y; p.vx = ax * speed; p.vy = ay * speed;
          p.r = 3 + Math.random() * 5; p.life = 700 + Math.random() * 500;
          c.setAttribute('fill', Math.random() < 0.7 ? '#fffbeb' : '#fef3c7');
          c.removeAttribute('stroke');
        } else {
          p.x = MOUTH.x + (Math.random() - 0.5) * 10; p.y = MOUTH.y - 4;
          p.vx = DIR.x * 30 + (Math.random() - 0.5) * 24; p.vy = -40 - Math.random() * 60;
          p.r = 1.4 + Math.random() * 3.2; p.life = 1500 + Math.random() * 1500;
          p.wob = Math.random() * 6; p.wf = 3 + Math.random() * 4;
          c.setAttribute('fill', 'rgba(254, 243, 199, .18)');
          c.setAttribute('stroke', Math.random() < 0.5 ? '#fde68a' : '#fffbeb');
          c.setAttribute('stroke-width', '1');
        }
        parts.push(p);
      }
      var foamAcc = 0, fizzAcc = 0;
      function step(dt) {
        var s = dt / 1000;
        foamAcc += S.foam * 0.09 * dt;
        fizzAcc += S.fizz * 0.03 * fizziness * dt;
        while (foamAcc >= 1) { foamAcc -= 1; spawn('foam'); }
        while (fizzAcc >= 1) { fizzAcc -= 1; spawn('bubble'); }
        for (var i = parts.length - 1; i >= 0; i--) {
          var p = parts[i];
          p.age += dt;
          var u = p.age / p.life;
          if (u >= 1) {
            p.el.setAttribute('opacity', 0);
            pool.push(p.el);
            parts.splice(i, 1);
            continue;
          }
          if (p.kind === 'foam') {
            p.vy += 260 * s;
            p.vx *= 1 - 1.2 * s; p.vy *= 1 - 0.6 * s;
            p.x += p.vx * s; p.y += p.vy * s;
            p.el.setAttribute('r', (p.r * (1 + u * 0.8)).toFixed(2));
            p.el.setAttribute('opacity', (1 - u * u).toFixed(2));
          } else {
            p.vy -= 30 * s;
            p.x += p.vx * s + Math.sin(p.age / 1000 * p.wf + p.wob) * 0.4; p.y += p.vy * s;
            p.el.setAttribute('r', (p.r * (0.6 + u * 0.6)).toFixed(2));
            p.el.setAttribute('opacity', (u < 0.15 ? u / 0.15 : 1 - Math.pow((u - 0.15) / 0.85, 2)).toFixed(2));
          }
          p.el.setAttribute('cx', p.x.toFixed(1));
          p.el.setAttribute('cy', p.y.toFixed(1));
        }
      }

      if (reduced()) {
        // The cork out and a few bubbles hanging in the air.
        S.cork = 0;
        render();
        for (var b = 0; b < 14; b++) {
          spawn('bubble');
          var p = parts[parts.length - 1];
          p.age = p.life * 0.4;
          p.x = MOUTH.x + (b % 5 - 2) * 12 + (b * 7) % 9; p.y = MOUTH.y - 12 - b * 9;
        }
        step(0);
        return;
      }
      render();

      var tl = null, visible = false, pending = false, 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;
          step(dt);
          render();
        }
      });

      function pop() {
        if (tl) tl.pause();
        reset();
        tl = anime.createTimeline({
          onComplete: function () {
            tl = null;
            if (!loop) return;
            if (visible) pop(); else pending = true;
          }
        });
        tl.add(S, { cork: [0, 1], duration: 500, ease: 'outBack(2)' })
          // The cork wriggles and creeps up while the bottle trembles.
          .add(S, { wig: [0, -7, 6, -8, 7, -5, 4, 0], lift: [0, 1, 2, 3, 4, 5, 6, 7], duration: 1100, ease: 'linear' }, '+=' + wait)
          .add(S, { shake: [0, 1.4, 0.4], duration: 1100, ease: 'linear' }, '<<')
          // Pop!
          .add(S, { fly: 330, spin: 540, duration: 520, ease: 'out(2)' })
          .add(S, { burst: [0, 1], duration: 420, ease: 'out(2)' }, '<<')
          .add(S, { shake: 0, duration: 200 }, '<<')
          .add(S, { foam: [1, 0], duration: 750, ease: 'in(1.5)' }, '<<')
          .add(S, { fizz: [{ to: 1, duration: 300 }, { to: 1, duration: 1400 }, { to: 0, duration: 2200, ease: 'in(1.2)' }] }, '<<+=150');
        if (loop) tl.add({ duration: 1200 });
      }
      el._chpPop = pop;

      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) { last = 0; timer.play(); if (tl) tl.play(); }
        else { timer.pause(); if (tl) tl.pause(); }
        if (visible && !started) { started = true; pop(); }
        else if (visible && pending) { pending = false; pop(); }
      }, { threshold: 0.25 }).observe(el);
    }

    window.animacious = window.animacious || {};
    window.animacious.champagnePop = function (el) { if (el && el._chpPop) el._chpPop(); };

    function init() {
      document.querySelectorAll('[data-champagne-pop]').forEach(function (el) {
        if (el._chp) return;   // idempotent
        el._chp = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-champagne-pop style="background: #0f172a"></div>

<!-- extra fizzy, popping again and again: -->
<div data-champagne-pop data-loop data-bubbles="2"></div>