← Loaders

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

  1. Paste the snippet just before </head>.
  2. Add data-progress-bar and data-value (0 to 100) to an empty <div>, or data-indeterminate instead of a value.
  3. As things progress, set data-value from your code: bar.dataset.value = 72.

Options

Add any of these attributes to the same element.

data-maxThe value of a full bar. Default 100.
data-stripedDiagonal stripes that crawl along the bar.
data-indeterminateNo value: a bar sweeps across, over and over.
data-colorOne colour, or two for a gradient. Default #a855f7,#6a0dad.
data-trackBackground colour. Default #e9e2f5.
data-heightHeight in pixels. Default 10.
data-show-valueWrite the percentage after the bar.
data-durationTime for each glide. Default 900 ms.
data-loopFill 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.

1 · Snippet
<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>
2 · Your HTML
<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>