Liquid Fill Progress
A round flask fills with liquid as progress rises. Two waves roll across the surface, and every time the value jumps the liquid sloshes, tipping one way and then the other before it settles. The percentage sits in the middle and turns white where the liquid covers it. Size it with font-size and colour it with color.
How to use it
- Paste the snippet just before
</head>. - Add
data-liquid-fill-progressanddata-value(0 to 100) to an empty<div>. - As things progress, set
data-valuefrom your code:flask.dataset.value = 72.
Options
Add any of these attributes to the same element.
data-label="false" | Hide the percentage. |
data-duration | Time for each rise. Default 900 ms. |
data-wave | How big the waves are, 0 to 3. Default 1. |
data-loop | Fill in uneven hops, hold, drain and start again. Handy for demos. |
It’s 4em across. It’s a real progressbar for screen readers, with aria-valuenow kept up to date; give it an aria-label. The waves only roll while it’s on screen. With reduced motion the liquid sits still at its level.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Liquid Fill Progress: a round flask fills with liquid as progress rises.
// Two waves roll across the surface, and every time the value jumps the
// liquid sloshes, tipping one way and the other before it settles. The
// percentage sits in the middle and turns white where the liquid covers it.
// Sized with font-size (it's 4em across), coloured with color.
// Usage: <div data-liquid-fill-progress data-value="40" aria-label="Uploading"></div>
// Then set data-value (0 to 100) from your code as things progress.
// Optional: data-label="false" (hide the percentage), data-duration (ms for
// each rise, default 900), data-wave (wave height, 0 to 3, default 1),
// data-loop (fill in uneven hops, hold, drain and start again: for demos).
// It only moves while it's on screen.
var DEFAULTS = { value: 0, duration: 900, wave: 1 };
var css =
'.lqf{display:inline-block;width:4em;height:4em;vertical-align:middle}' +
'.lqf svg{display:block;width:100%;height:100%;overflow:visible}' +
'.lqf-ring{fill:none;stroke:currentColor;stroke-width:3}' +
'.lqf-back{fill:currentColor;opacity:.4}' +
'.lqf-front{fill:currentColor}' +
'.lqf-txt{font:800 21px/1 system-ui,-apple-system,"Segoe UI",sans-serif;text-anchor:middle;dominant-baseline:central;font-variant-numeric:tabular-nums}' +
'.lqf-dark{fill:currentColor}' +
'.lqf-light{fill:#fff}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clampV(v) { return Math.max(0, Math.min(100, isNaN(v) ? 0 : v)); }
// A wave surface across the 100 x 100 box, filled down to the bottom.
function wave(level, amp, len, phase, tilt) {
var d = '';
for (var x = -10; x <= 110; x += 5) {
var y = level + amp * Math.sin(x / len * Math.PI * 2 + phase) + tilt * (x - 50);
d += (x === -10 ? 'M' : 'L') + x + ',' + y.toFixed(2);
}
return d + 'L110,110L-10,110Z';
}
function build(el) {
var d = el.dataset;
var id = 'lqf-' + (++uid);
var showLabel = d.label !== 'false';
var loop = d.loop !== undefined && d.loop !== 'false';
var dur = Math.max(100, +d.duration || DEFAULTS.duration);
var waveH = Math.max(0, Math.min(3, d.wave != null && !isNaN(+d.wave) ? +d.wave : DEFAULTS.wave));
el.classList.add('lqf');
el.setAttribute('role', 'progressbar');
el.setAttribute('aria-valuemin', '0');
el.setAttribute('aria-valuemax', '100');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Progress');
el.innerHTML =
'<svg viewBox="0 0 100 100" aria-hidden="true"><defs>' +
'<clipPath id="' + id + '-c"><circle cx="50" cy="50" r="42"/></clipPath>' +
'<clipPath id="' + id + '-w"><path class="lqf-wclip"/></clipPath></defs>' +
'<circle class="lqf-ring" cx="50" cy="50" r="47.5"/>' +
'<g clip-path="url(#' + id + '-c)"><path class="lqf-back"/><path class="lqf-front"/></g>' +
(showLabel ? '<text class="lqf-txt lqf-dark" x="50" y="50"></text>' +
'<g clip-path="url(#' + id + '-w)"><text class="lqf-txt lqf-light" x="50" y="50"></text></g>' : '') +
'</svg>';
var back = el.querySelector('.lqf-back');
var front = el.querySelector('.lqf-front');
var wclip = el.querySelector('.lqf-wclip');
var texts = el.querySelectorAll('text');
var s = { v: clampV(loop ? 0 : +(d.value || DEFAULTS.value)), kick: 0, t: 0 };
function label() {
var n = Math.round(s.v);
for (var i = 0; i < texts.length; i++) texts[i].textContent = n + '%';
el.setAttribute('aria-valuenow', n);
}
function draw() {
// Empty sits just below the flask; full just above it.
var level = 95 - 90 * s.v / 100;
var calm = s.v <= 0 || s.v >= 100 ? 0.4 : 1;
var amp = (2.2 + s.kick * 2) * waveH * calm;
var tilt = s.kick * 0.12 * Math.sin(s.t * 0.006);
var f = wave(level, amp, 48, s.t * 0.005, tilt);
front.setAttribute('d', f);
wclip.setAttribute('d', f);
back.setAttribute('d', wave(level - 1.5, amp * 0.9, 60, -s.t * 0.0035 + 2, -tilt * 0.7));
label();
}
var rise = null;
function setValue(v) {
v = clampV(v);
var delta = Math.abs(v - s.v);
if (rise) rise.pause();
s.kick = Math.min(1.6, s.kick + 0.3 + delta / 40);
rise = anime.animate(s, { v: v, duration: dur, ease: 'out(3)' });
}
draw();
if (reduced()) {
if (loop) { s.v = 65; draw(); }
new MutationObserver(function () { s.v = clampV(+d.value); draw(); })
.observe(el, { attributes: true, attributeFilter: ['data-value'] });
return null;
}
new MutationObserver(function () { if (!loop) setValue(+d.value); })
.observe(el, { attributes: true, attributeFilter: ['data-value'] });
// Demo mode: fill in uneven hops, hold, drain, repeat.
var steps = [18, 41, 47, 72, 88, 100], step = -1, wait = 600;
var 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;
last = now;
s.t += dt;
s.kick *= Math.pow(0.5, dt / 900); // the slosh dies away
if (loop) {
wait -= dt;
if (wait <= 0) {
step = (step + 1) % (steps.length + 1);
if (step < steps.length) { setValue(steps[step]); wait = dur + 300 + Math.random() * 500; }
else { setValue(0); wait = dur + 900; }
if (step === steps.length - 1) wait += 1400; // hold when full
}
}
draw();
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-liquid-fill-progress]').forEach(function (el) {
if (el._lqf) return;
el._lqf = 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-liquid-fill-progress data-value="40" aria-label="Uploading"
style="font-size: 24px; color: #6a0dad"></div>
<script>
// later:
document.querySelector("[data-liquid-fill-progress]").dataset.value = 100;
</script>