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
- Paste the snippet just before
</head>. - 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-loop | Put a new cork in and pop it again. |
data-color | Colour of the glass. Default bottle green #166534. |
data-bubbles | How fizzy, as a multiplier. Default 1. |
data-delay | How 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.
<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><div data-champagne-pop style="background: #0f172a"></div>
<!-- extra fizzy, popping again and again: -->
<div data-champagne-pop data-loop data-bubbles="2"></div>