Progress Bar
A smooth bar that glides to its value, with a soft glint on its leading edge while it moves. Change data-value from your code and it glides to the new value. Add crawling stripes, or switch to the indeterminate mode, a bar sweeping across, when you can’t measure progress.
holiday-video.mp4Uploading
Installing updates
Connecting
How to use it
- Paste the snippet just before
</head>. - Add
data-progress-baranddata-value(0 to 100) to an empty<div>, ordata-indeterminateinstead of a value. - As things progress, set
data-valuefrom your code:bar.dataset.value = 72.
Options
Add any of these attributes to the same element.
data-max | The value of a full bar. Default 100. |
data-striped | Diagonal stripes that crawl along the bar. |
data-indeterminate | No value: a bar sweeps across, over and over. |
data-color | One colour, or two for a gradient. Default #a855f7,#6a0dad. |
data-track | Background colour. Default #e9e2f5. |
data-height | Height in pixels. Default 10. |
data-show-value | Write the percentage after the bar. |
data-duration | Time for each glide. Default 900 ms. |
data-loop | Fill up in uneven hops, like a real upload, hold, and start again. Handy for demos. |
It starts when scrolled into view. It’s a real progressbar for screen readers, with aria-valuenow kept up to date; give it an aria-label. With reduced motion it jumps straight to each value.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Progress Bar: a smooth bar that glides to its value, with a soft glint on
// its leading edge while it moves. Change data-value from your code and it
// glides to the new value. Add stripes that crawl along it, or use the
// indeterminate mode (a bar sweeping across) when you can't measure progress.
// It's a real progressbar for screen readers, with its value kept up to date.
// Usage: <div data-progress-bar data-value="64" aria-label="Uploading"></div>
// <div data-progress-bar data-indeterminate aria-label="Loading"></div>
// Optional: data-max (default 100), data-striped (moving diagonal stripes),
// data-color (one colour, or two for a gradient, default #a855f7,#6a0dad),
// data-track (default #e9e2f5), data-height (px, default 10),
// data-show-value (writes the percentage after the bar), data-duration (ms
// per glide, default 900), data-loop (it fills up in uneven hops, like a
// real upload, holds, and starts again; handy for demos).
var DEFAULTS = { color: '#a855f7,#6a0dad', track: '#e9e2f5', height: 10, duration: 900, hold: 1400 };
var css =
'.pgb{display:flex;align-items:center;gap:0.75em;width:100%}' +
'.pgb-track{position:relative;flex:1;height:var(--pgb-h);border-radius:999px;overflow:hidden;background:var(--pgb-track)}' +
'.pgb-fill,.pgb-ind{position:absolute;top:0;bottom:0;left:0;border-radius:inherit;background:var(--pgb-fill)}' +
'.pgb-fill{width:0}' +
'.pgb-fill::after{content:"";position:absolute;top:0;bottom:0;right:0;width:min(3em,100%);border-radius:inherit;' +
'background:linear-gradient(90deg,rgba(255,255,255,0),rgba(255,255,255,.6));opacity:var(--pgb-glint,0)}' +
'.pgb-striped .pgb-fill,.pgb-striped .pgb-ind{background:linear-gradient(135deg,rgba(255,255,255,.26) 25%,transparent 25%,transparent 50%,' +
'rgba(255,255,255,.26) 50%,rgba(255,255,255,.26) 75%,transparent 75%,transparent) var(--pgb-x,0) 0/calc(var(--pgb-h) * 2) 100%,var(--pgb-fill)}' +
'.pgb-num{min-width:2.6em;font-weight:700;font-variant-numeric:tabular-nums;text-align:right}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var reduced = function () { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; };
var on = function (v) { return v !== undefined && v !== 'false'; };
function build(el) {
var d = el.dataset;
var colors = (d.color || DEFAULTS.color).split(',');
var h = +d.height || DEFAULTS.height;
var b = {
el: el, max: parseFloat(d.max) || 100, state: { v: 0 }, indeterminate: on(d.indeterminate), seen: false
};
el.classList.add('pgb');
if (on(d.striped)) el.classList.add('pgb-striped');
el.style.setProperty('--pgb-h', h + 'px');
el.style.setProperty('--pgb-glint', 0);
el.style.setProperty('--pgb-x', '0px');
el.style.setProperty('--pgb-track', d.track || DEFAULTS.track);
el.style.setProperty('--pgb-fill', colors.length > 1 ?
'linear-gradient(90deg,' + colors.map(function (c) { return c.trim(); }).join(',') + ')' : colors[0].trim());
el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Progress');
el.innerHTML = '<div class="pgb-track"></div>';
var track = el.firstChild;
if (b.indeterminate) {
// Two bars of different lengths and speeds, like Material's, so the
// sweep never looks the same twice in a row.
track.innerHTML = '<div class="pgb-ind"></div><div class="pgb-ind"></div>';
b.bars = Array.from(track.children);
return b;
}
el.setAttribute('aria-valuemin', 0);
el.setAttribute('aria-valuemax', b.max);
track.innerHTML = '<div class="pgb-fill"></div>';
b.fill = track.firstChild;
if (on(d.showValue)) {
b.num = document.createElement('span');
b.num.className = 'pgb-num';
b.num.setAttribute('aria-hidden', 'true');
el.appendChild(b.num);
}
return b;
}
function target(b) {
var v = parseFloat(b.el.dataset.value) || 0;
return Math.max(0, Math.min(b.max, v));
}
function render(b) {
var share = b.state.v / b.max;
b.fill.style.width = share * 100 + '%';
if (b.num) b.num.textContent = Math.round(share * 100) + '%';
}
function glide(b) {
var to = target(b);
b.el.setAttribute('aria-valuenow', Math.round(to * 100) / 100);
if (reduced()) { b.state.v = to; render(b); return; }
var d = b.el.dataset;
// A glint shows on the leading edge only while the bar is moving.
anime.animate(b.el, { '--pgb-glint': [{ to: 1, duration: 150 }, { to: 0, duration: 500, delay: (+d.duration || DEFAULTS.duration) * 0.6 }] });
anime.animate(b.state, { v: to, duration: +d.duration || DEFAULTS.duration, ease: 'out(3)', onUpdate: function () { render(b); } });
}
// data-loop: hop up to full in uneven steps, hold, empty, repeat.
function demo(b) {
var v = target(b);
var next;
if (v >= b.max) next = 0;
else next = Math.min(b.max, v + b.max * (0.08 + Math.random() * 0.3));
// Wait for the last glide to mostly finish, then pause like a real upload.
var wait = (+b.el.dataset.duration || DEFAULTS.duration) * 0.6 +
(v >= b.max ? DEFAULTS.hold : v === 0 ? 600 : 200 + Math.random() * 600);
b.timer = setTimeout(function () {
b.el.dataset.value = Math.round(next);
demo(b);
}, wait);
}
function start(b) {
var d = b.el.dataset;
if (b.indeterminate) {
if (reduced()) { anime.utils.set(b.bars[0], { left: '0%', right: '0%', opacity: 0.45 }); return; }
if (b.anims) { b.anims.forEach(function (a) { a.play(); }); return; }
var lap = 2100;
b.anims = [
anime.animate(b.bars[0], {
left: [{ from: '-35%', to: '100%', duration: lap * 0.6, ease: 'inOut(1.6)' }, { to: '100%', duration: lap * 0.4 }],
right: [{ from: '100%', to: '-90%', duration: lap * 0.6, ease: 'inOut(1.6)' }, { to: '-90%', duration: lap * 0.4 }],
loop: true
}),
anime.animate(b.bars[1], {
left: [{ from: '-200%', to: '107%', duration: lap * 0.6, ease: 'inOut(1.6)' }, { to: '107%', duration: lap * 0.4 }],
right: [{ from: '100%', to: '-8%', duration: lap * 0.6, ease: 'inOut(1.6)' }, { to: '-8%', duration: lap * 0.4 }],
delay: lap * 0.55,
loop: true
})
];
}
if (on(d.striped) && !reduced() && !b.stripes) {
b.stripes = anime.animate(b.el, { '--pgb-x': ['0px', (+d.height || DEFAULTS.height) * 2 + 'px'], duration: 700, ease: 'linear', loop: true });
} else if (b.stripes) b.stripes.play();
if (b.indeterminate) return;
if (!b.seen) {
b.seen = true;
glide(b);
if (on(d.loop) && !reduced()) demo(b);
} else if (on(d.loop) && !b.timer && !reduced()) demo(b);
}
function stop(b) {
if (b.anims) b.anims.forEach(function (a) { a.pause(); });
if (b.stripes) b.stripes.pause();
clearTimeout(b.timer);
b.timer = null;
}
function init() {
document.querySelectorAll('[data-progress-bar]').forEach(function (el) {
if (el._pgb) return;
var b = (el._pgb = build(el));
if (!b.indeterminate) {
render(b);
// Glide whenever data-value changes (once it has been seen).
new MutationObserver(function () { if (b.seen) glide(b); })
.observe(el, { attributes: true, attributeFilter: ['data-value'] });
}
// Start on screen; pause the moving parts when off screen.
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? start(b) : stop(b);
}, { threshold: 0.2 }).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-progress-bar data-value="64" aria-label="Uploading"></div>
<script>
// later, as the upload progresses:
document.querySelector("[data-progress-bar]").dataset.value = 80;
</script>
<!-- striped, or indeterminate: -->
<div data-progress-bar data-value="40" data-striped aria-label="Installing"></div>
<div data-progress-bar data-indeterminate aria-label="Connecting"></div>