Coffee Steam
Soft wisps of steam curl up from a fresh mug of coffee, leaning and twisting as they rise and fading into the air. They come and go, so it never looks like a loop.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-coffee-steam>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-color | Colour of the mug. Default #7c3aed. |
data-steam | Colour of the steam. Default #94a3b8; use white on a dark background. |
data-wisps | Number of wisps, 1 to 5. Default 3. |
data-speed | Speed multiplier. Default 1. |
It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion the steam holds still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Coffee Steam: soft wisps of steam curl and drift up from a mug of
// coffee, fading out as they rise. Drawn as SVG that fills the element,
// and paused when off screen.
// Usage: <div data-coffee-steam></div>
// It is 8:5 by default on a transparent background.
// Optional: data-color (mug colour, default #7c3aed), data-steam (steam
// colour, default #94a3b8; use white on a dark background), data-wisps
// (number of wisps, 1 to 5, default 3), data-speed (default 1).
var DEFAULTS = { color: '#7c3aed', steam: '#94a3b8', wisps: 3, speed: 1 };
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var BASE = 124, TOP = 14; // where steam starts and where it has faded
var css =
'[data-coffee-steam]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
'.cst-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
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 mug = d.color || DEFAULTS.color;
var steam = d.steam || DEFAULTS.steam;
var count = Math.max(1, Math.min(5, Math.round(+d.wisps || DEFAULTS.wisps)));
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Steam rising from a mug of coffee');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var g = 'cst' + n;
el.innerHTML =
'<svg class="cst-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs>' +
'<linearGradient id="' + g + '-fade" gradientUnits="userSpaceOnUse" x1="0" y1="' + BASE + '" x2="0" y2="' + TOP + '">' +
'<stop offset="0" stop-color="' + steam + '" stop-opacity="0"/><stop offset=".14" stop-color="' + steam + '" stop-opacity=".9"/>' +
'<stop offset=".55" stop-color="' + steam + '" stop-opacity=".45"/><stop offset="1" stop-color="' + steam + '" stop-opacity="0"/>' +
'</linearGradient>' +
'<filter id="' + g + '-soft" x="-50%" y="-10%" width="200%" height="120%"><feGaussianBlur stdDeviation="1.6"/></filter>' +
'<linearGradient id="' + g + '-mug" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity=".22"/><stop offset=".35" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".18"/></linearGradient>' +
'</defs>' +
'<g class="cst-steam" fill="none" stroke="url(#' + g + '-fade)" stroke-linecap="round" filter="url(#' + g + '-soft)"></g>' +
'<ellipse cx="200" cy="226" rx="92" ry="12" fill="#1e1b2e" opacity=".08"/>' +
'<ellipse cx="200" cy="220" rx="86" ry="11" fill="#e2e8f0"/>' +
'<ellipse cx="200" cy="217" rx="62" ry="7" fill="#cbd5e1"/>' +
'<path d="M246 152C278 148 282 196 244 196" fill="none" stroke="' + mug + '" stroke-width="11" stroke-linecap="round"/>' +
'<path d="M152 130L248 130L244 200C243 210 234 216 222 216L178 216C166 216 157 210 156 200Z" fill="' + mug + '"/>' +
'<path d="M152 130L248 130L244 200C243 210 234 216 222 216L178 216C166 216 157 210 156 200Z" fill="url(#' + g + '-mug)"/>' +
'<ellipse cx="200" cy="130" rx="48" ry="8" fill="' + mug + '"/>' +
'<ellipse cx="200" cy="131" rx="43" ry="6" fill="#5b3418"/>' +
'<path d="M200 135C196 131 190 131 190 127.5C190 125 193 124 195 125.5C197 126.5 198 128 200 129C202 128 203 126.5 205 125.5C207 124 210 125 210 127.5C210 131 204 131 200 135Z" fill="#d6b48c" opacity=".85"/>' +
'<path d="M162 146C163 168 165 186 168 200" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity=".22"/>' +
'</svg>';
var svg = el.firstChild;
var group = svg.querySelector('.cst-steam');
var wisps = [];
for (var i = 0; i < count; i++) {
var x0 = 200 + (count === 1 ? 0 : (i / (count - 1) - 0.5) * 50);
var wide = document.createElementNS(NS, 'path');
wide.setAttribute('stroke-width', '13');
wide.setAttribute('opacity', '.28');
var thin = document.createElementNS(NS, 'path');
thin.setAttribute('stroke-width', '4.5');
group.appendChild(wide);
group.appendChild(thin);
wisps.push({ x0: x0, wide: wide, thin: thin, ph: i * 2.3 + Math.random(), f: 0.85 + Math.random() * 0.3 });
}
function frame(t) {
var s = t / 1000;
wisps.forEach(function (w) {
var dPath = '';
for (var y = BASE; y >= TOP; y -= 4) {
var h = (BASE - y) / (BASE - TOP);
// A wave travelling upwards that widens with height, plus a slow
// sideways drift, so each wisp curls and leans.
var x = w.x0 +
(3 + 18 * h) * Math.sin(h * 7.5 - s * 2.1 * w.f + w.ph) +
14 * h * h * Math.sin(s * 0.45 + w.ph * 1.7);
dPath += (y === BASE ? 'M' : 'L') + x.toFixed(1) + ' ' + y;
}
w.wide.setAttribute('d', dPath);
w.thin.setAttribute('d', dPath);
// Wisps come and go.
var a = 0.55 + 0.45 * Math.sin(s * 0.7 * w.f + w.ph * 2.1);
w.thin.setAttribute('opacity', a.toFixed(2));
w.wide.setAttribute('opacity', (0.28 * a).toFixed(2));
});
}
if (reduced()) { frame(1200); return; }
var t = 0, last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += (last ? Math.min(50, now - last) : 16) * speed;
last = now;
frame(t);
}
});
frame(0);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-coffee-steam]').forEach(function (el) {
if (el._cst) return; // idempotent
el._cst = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-coffee-steam></div>
<h2>Taking a break</h2>
<!-- on a dark background: -->
<div data-coffee-steam data-steam="#fff" data-color="#f59e0b"></div>