Coffee Cup Fill
A cup fills with coffee, its surface rippling under a line of crema, while wisps of steam curl up from it, growing stronger as it fills. On its own it fills, holds, empties and fills again. Give it a data-value and it becomes a progress meter that rises to match. Size it with font-size and colour the cup with color.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-coffee-cup-fill>where the loader should appear. It fills and empties on a loop. - Or, to show real progress, add
data-value(0 to 100) and update it from your code:cup.dataset.value = 72.
Options
Add any of these attributes to the same element.
data-value | Turn it into a progress meter filled to this percentage. Change it and the coffee rises to match. |
data-duration | Time to fill the cup when looping. Default 2600 ms. |
data-coffee | Coffee colour. Default #6f4e37. |
data-crema | Colour of the foam line on top. Default #c8a27a. |
data-steam="false" | No steam. |
It’s 3em across. It’s a progressbar for screen readers, with aria-valuenow kept up to date when you use data-value. It only moves while it’s on screen. With reduced motion the cup sits still, nearly full (or at its value).
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Coffee Cup Fill: a cup fills with coffee, its surface rippling, while
// wisps of steam curl up from it, growing stronger as it fills. On its own
// it fills, holds, empties and fills again. Give it a data-value and it
// becomes a progress meter that rises to match.
// Sized with font-size (3em across), coloured with color (the cup).
// It only moves while it's on screen.
// Usage: <div data-coffee-cup-fill aria-label="Brewing"></div>
// or: <div data-coffee-cup-fill data-value="40" aria-label="Uploading"></div>
// (then set data-value, 0 to 100, from your code as things progress)
// Optional: data-duration (ms to fill the cup when looping, default 2600),
// data-coffee (coffee colour, default #6f4e37), data-crema (colour of the
// foam line, default #c8a27a), data-steam="false" (no steam).
var DEFAULTS = { duration: 2600, coffee: '#6f4e37', crema: '#c8a27a' };
var TOP = 20, BOTTOM = 41; // coffee level when full and empty (view box units)
var css =
'.ccf{display:inline-block;width:3em;height:3em;vertical-align:middle}' +
'.ccf svg{display:block;width:100%;height:100%;overflow:visible}' +
'.ccf-cup{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}' +
'.ccf-in{fill:currentColor;opacity:.08}' +
'.ccf-steam{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;opacity:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var CUP = 'M8,17L10,37Q10.6,41 15,41L27,41Q31.4,41 32,37L34,17';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function make(parent, tag, attrs) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
parent.appendChild(n);
return n;
}
function inOut(v) { return v < 0.5 ? 2 * v * v : 1 - Math.pow(-2 * v + 2, 2) / 2; }
function clampV(v) { return Math.max(0, Math.min(100, isNaN(v) ? 0 : v)); }
function build(el) {
var d = el.dataset;
var id = 'ccf-' + (++uid);
var meter = d.value != null && d.value !== '';
el.classList.add('ccf');
el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
if (meter) { el.setAttribute('aria-valuemin', '0'); el.setAttribute('aria-valuemax', '100'); }
el.textContent = '';
var svg = make(el, 'svg', { viewBox: '0 0 48 48', 'aria-hidden': 'true' });
var clip = make(make(svg, 'defs', {}), 'clipPath', { id: id + '-c' });
make(clip, 'path', { d: CUP + 'Z' });
var steam = [];
if (d.steam !== 'false') {
[15, 21, 27].forEach(function (x) { steam.push({ x: x, el: make(svg, 'path', { class: 'ccf-steam' }) }); });
}
make(svg, 'path', { class: 'ccf-in', d: CUP + 'Z' });
var g = make(svg, 'g', { 'clip-path': 'url(#' + id + '-c)' });
var liquid = make(g, 'path', { fill: d.coffee || DEFAULTS.coffee });
var crema = make(g, 'path', { fill: 'none', stroke: d.crema || DEFAULTS.crema, 'stroke-width': '1.6' });
make(svg, 'path', { class: 'ccf-cup', d: CUP });
make(svg, 'path', { class: 'ccf-cup', d: 'M33.5,21.5C40.5,20.5 40.5,32.5 32.4,32' });
make(svg, 'path', { class: 'ccf-cup', d: 'M4,45L40,45' });
function surface(y, t, amp) {
var s = '';
for (var x = 6; x <= 36; x += 2) {
var yy = y + amp * Math.sin(x / 4.5 + t / 260) + amp * 0.5 * Math.sin(x / 2.3 - t / 410);
s += (x === 6 ? 'M' : 'L') + x + ',' + yy.toFixed(2);
}
return s;
}
// f: how full, 0 to 1; t: time in ms (for the ripples and steam).
function draw(f, t) {
var y = BOTTOM + 1.5 - (BOTTOM + 1.5 - TOP) * f;
var amp = f > 0.02 ? 0.45 : 0;
var top = surface(y, t, amp);
liquid.setAttribute('d', top + 'L36,48L6,48Z');
crema.setAttribute('d', top);
crema.style.opacity = f > 0.04 ? '1' : '0';
var strength = Math.min(1, f * 1.4);
steam.forEach(function (s, i) {
var u = ((t / 2200 + i * 0.37) % 1 + 1) % 1;
var base = 14 - u * 7, p = '';
for (var k = 0; k <= 16; k++) {
var yy = base - k * 0.45;
var xx = s.x + Math.sin(k * 0.36 + t / 320 + i * 2) * (0.5 + k * 0.08);
p += (k ? 'L' : 'M') + xx.toFixed(2) + ',' + yy.toFixed(2);
}
s.el.setAttribute('d', p);
s.el.style.opacity = (Math.sin(u * Math.PI) * 0.55 * strength).toFixed(3);
});
}
var level = meter ? clampV(+d.value) / 100 : 0;
if (meter) el.setAttribute('aria-valuenow', String(Math.round(level * 100)));
if (meter) {
new MutationObserver(function () {
el.setAttribute('aria-valuenow', String(Math.round(clampV(+d.value))));
if (reduced()) draw(clampV(+d.value) / 100, 0);
}).observe(el, { attributes: true, attributeFilter: ['data-value'] });
}
if (reduced()) { draw(meter ? level : 0.8, 0); return null; }
draw(meter ? level : 0, 0);
var dur = Math.max(400, +d.duration || DEFAULTS.duration);
var hold = 1400, drain = 700, rest = 400, cycle = dur + hold + drain + rest;
var t = 0, last = 0;
return anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
t += dt;
last = now;
var f;
if (meter) {
// Ease towards the current value.
level += (clampV(+d.value) / 100 - level) * Math.min(1, dt / 220);
f = level;
} else {
var c = t % cycle;
f = c < dur ? inOut(c / dur) : c < dur + hold ? 1 : c < dur + hold + drain ? 1 - inOut((c - dur - hold) / drain) : 0;
}
draw(f, t);
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-coffee-cup-fill]').forEach(function (el) {
if (el._ccf) return;
el._ccf = true;
var timer = build(el);
if (!timer) return;
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? timer.play() : timer.pause();
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-coffee-cup-fill style="font-size: 24px; color: #6a0dad" aria-label="Brewing"></div>
<!-- as a progress meter: -->
<div data-coffee-cup-fill data-value="40" aria-label="Uploading"></div>
<script>
document.querySelector("[data-coffee-cup-fill]").dataset.value = 100;
</script>