← Buttons

Download Progress Button

Press download and the arrow dips, then drops into its tray as the label slips away. The button turns into a striped progress bar that fills up in fits and starts, its percentage changing colour as the fill passes under it, and it finishes green with a tick before turning back into a button.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-download-progress-button to a button with a solid background. The script adds the arrow icon.
  3. Start the download when it fires its download event.

Options

Add any of these attributes to the same element.

data-colorColour of the fill. Default #6a0dad.
data-trackColour of the empty bar. Default a pale tint of the fill.
data-doneLabel when finished. Default “Done”.
data-done-colorColour when finished. Default #16a34a.
data-durationHow long the pretend download takes. Default 2600 ms.
data-holdHow long the tick stays. Default 1600 ms.
data-manualReport real progress yourself (see below) instead of the pretend download.
data-autoplayIt downloads by itself every few seconds while in view, without firing the event. Handy for demos.

With data-manual, call animacious.downloadProgress(button, p) with p from 0 to 1 as your download goes; at 1 it shows the tick. While it runs the button is a progressbar for screen readers.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Download Progress Button: press it and the arrow drops into its tray,
    // the button turns into a progress bar that fills up with a percentage,
    // and it finishes with a tick before turning back into a button.
    // Usage: <button data-download-progress-button>Download</button>
    // Optional: data-color (colour of the fill, default #6a0dad), data-track
    // (colour of the empty bar, default a pale tint of the fill), data-done
    // (label when finished, default "Done"), data-done-color (colour when
    // finished, default #16a34a), data-duration (ms the pretend download
    // takes, default 2600), data-hold (ms the finished state stays, default
    // 1600), data-manual (you report real progress instead, see below),
    // data-autoplay (it downloads by itself every few seconds while in view,
    // without firing the event; handy for demos).
    // Events: fires "download" on the button when pressed.
    // Script use: with data-manual, call animacious.downloadProgress(button, p)
    // with p from 0 to 1 as your download goes; at 1 it shows the tick.
    var DEFAULTS = { color: '#6a0dad', done: 'Done', doneColor: '#16a34a', duration: 2600, hold: 1600, every: 2600 };
    var ARROW = '<svg class="dpb-icon" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
      '<g class="dpb-arrow"><path d="M12 3.5v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/></g><path d="M4.5 19.5h15"/></svg>';
    var TICK = '<svg class="dpb-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" pathLength="1" d="M5 12.5l4.5 4.5L19 7.5"/></svg>';

    var css =
      '.dpb{position:relative;isolation:isolate;overflow:hidden}' +
      '.dpb-track,.dpb-fill{position:absolute;inset:0;z-index:-1;border-radius:inherit;pointer-events:none;opacity:0}' +
      '.dpb-fill{clip-path:inset(0 100% 0 0);background-image:repeating-linear-gradient(-45deg,rgba(255,255,255,.16) 0 8px,transparent 8px 16px);' +
      'background-size:22.6px 22.6px;animation:dpb-stripes .6s linear infinite}' +
      '@keyframes dpb-stripes{to{background-position:22.6px 0}}' +
      '.dpb-label,.dpb-done{display:inline-flex;align-items:center;gap:.5em}' +
      '.dpb-pct,.dpb-done{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:.45em;pointer-events:none;opacity:0;font-variant-numeric:tabular-nums}' +
      '.dpb-fill .dpb-pct{color:#fff}' +
      '.dpb-done{color:#fff}' +
      '.dpb-icon{width:1.15em;height:1.15em;flex:none;overflow:visible}' +
      '.dpb-done path{stroke-dasharray:1 1;stroke-dashoffset:1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var doneColor = d.doneColor || DEFAULTS.doneColor;
      var duration = +d.duration || DEFAULTS.duration;
      var hold = +d.hold || DEFAULTS.hold;
      var manual = d.manual !== undefined && d.manual !== 'false';
      el.classList.add('dpb');

      var label = document.createElement('span');
      label.className = 'dpb-label';
      while (el.firstChild) label.appendChild(el.firstChild);
      label.insertAdjacentHTML('afterbegin', ARROW);
      var arrow = label.querySelector('.dpb-arrow');

      var track = document.createElement('span');
      track.className = 'dpb-track';
      track.style.background = d.track || 'color-mix(in srgb,' + color + ' 16%,#fff)';
      var pct = document.createElement('span');
      pct.className = 'dpb-pct';
      pct.style.color = color;
      pct.setAttribute('aria-hidden', 'true');
      var fill = document.createElement('span');
      fill.className = 'dpb-fill';
      fill.style.backgroundColor = color;
      var pctIn = pct.cloneNode();
      pctIn.style.color = '';
      pctIn.style.opacity = 1;
      fill.appendChild(pctIn);
      var done = document.createElement('span');
      done.className = 'dpb-done';
      done.setAttribute('aria-hidden', 'true');
      done.innerHTML = TICK + '<span></span>';
      done.lastChild.textContent = d.done || DEFAULTS.done;
      var tick = done.querySelector('path');
      [track, fill, label, pct, done].forEach(function (n) { el.appendChild(n); });

      var state = 'idle', shown = 0, prog = { p: 0 }, still = reduced();

      function draw(p) {
        fill.style.clipPath = 'inset(0 ' + (100 - p * 100) + '% 0 0)';
        var text = Math.round(p * 100) + '%';
        if (pct.textContent !== text) {
          pct.textContent = pctIn.textContent = text;
          el.setAttribute('aria-valuenow', Math.round(p * 100));
        }
      }

      function begin() {
        if (state !== 'idle') return false;
        state = 'loading';
        el.style.width = el.offsetWidth + 'px';
        el.setAttribute('role', 'progressbar');
        el.setAttribute('aria-valuemin', '0');
        el.setAttribute('aria-valuemax', '100');
        label.setAttribute('aria-hidden', 'true');
        shown = 0;
        prog.p = 0;
        draw(0);
        var t = still ? 0 : 1;
        // The arrow dips, then drops into the tray as the label slips away.
        anime.animate(arrow, { y: [0, -3, 16], opacity: [1, 1, 0], duration: 420 * t, ease: 'in(2)' });
        anime.animate(label, { opacity: 0, y: 6, duration: 220 * t, delay: 260 * t, ease: 'in(2)' });
        anime.animate([track, fill], { opacity: 1, duration: 260 * t, delay: 360 * t, ease: 'out(2)' });
        anime.animate(pct, { opacity: [0, 1], scale: [0.8, 1], duration: 260 * t, delay: 420 * t, ease: 'out(3)' });
        anime.animate(el, { scaleY: [1, 0.9, 1], duration: 500 * t, delay: 320 * t, ease: 'out(3)' });
        return true;
      }

      function setProgress(p) {
        if (state !== 'loading') return;
        p = Math.max(shown, Math.min(1, p));
        shown = p;
        anime.animate(prog, {
          p: p, duration: still ? 0 : 320, ease: 'out(2)',
          onUpdate: function () { draw(prog.p); },
          onComplete: function () { if (p >= 1 && state === 'loading') finish(); }
        });
      }

      function simulate() {
        // A believable download: quick bursts and short stalls.
        var steps = [0.18, 0.31, 0.58, 0.66, 0.89, 1];
        var tl = anime.createTimeline({ delay: still ? 0 : 520, onComplete: finish });
        steps.forEach(function (p, i) {
          tl.add(prog, {
            p: p, duration: still ? 0 : duration / steps.length * (i % 2 ? 0.7 : 1.3), ease: 'inOut(2)',
            onUpdate: function () { draw(prog.p); }
          }, i ? '+=' + (still ? 0 : 60 + (i % 3) * 90) : 0);
        });
      }

      function finish() {
        if (state !== 'loading') return;
        state = 'done';
        draw(1);
        var t = still ? 0 : 1;
        anime.animate(pct, { opacity: 0, duration: 160 * t, ease: 'in(2)' });
        anime.animate(pctIn, { opacity: 0, duration: 160 * t, ease: 'in(2)' });
        anime.animate(fill, { backgroundColor: doneColor, duration: 300 * t, ease: 'out(2)' });
        anime.animate(el, { scale: [1, 1.06, 1], duration: 520 * t, delay: 120 * t, ease: 'outBack(2)' });
        anime.animate(done, { opacity: [0, 1], y: [8, 0], duration: 320 * t, delay: 140 * t, ease: 'out(3)' });
        anime.animate(tick, { strokeDashoffset: [1, 0], duration: 420 * t, delay: 260 * t, ease: 'out(2)' });
        done.removeAttribute('aria-hidden');
        el.setAttribute('aria-valuenow', '100');
        setTimeout(reset, hold + 300);
      }

      function reset() {
        var t = still ? 0 : 1;
        anime.animate(done, { opacity: 0, y: -6, duration: 220 * t, ease: 'in(2)' });
        anime.animate([track, fill], {
          opacity: 0, duration: 320 * t, delay: 120 * t, ease: 'inOut(2)',
          onComplete: function () {
            fill.style.backgroundColor = color;
            pctIn.style.opacity = 1;
            anime.utils.set(tick, { strokeDashoffset: 1 });
          }
        });
        anime.animate(label, { opacity: [0, 1], y: [8, 0], duration: 320 * t, delay: 260 * t, ease: 'out(3)' });
        anime.animate(arrow, {
          y: [-10, 0], opacity: [0, 1], duration: 420 * t, delay: 320 * t, ease: 'outBack(2)',
          onComplete: function () {
            ['role', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow'].forEach(function (a) { el.removeAttribute(a); });
            label.removeAttribute('aria-hidden');
            done.setAttribute('aria-hidden', 'true');
            el.style.width = '';
            state = 'idle';
          }
        });
      }

      el._dpbProgress = function (p) {
        if (state === 'idle') begin();
        setProgress(p);
      };

      function start(fire) {
        if (state !== 'idle') return;
        if (fire) el.dispatchEvent(new CustomEvent('download', { bubbles: true }));
        if (begin() && !(manual && fire)) simulate();
      }

      el.addEventListener('click', function () { start(true); });

      if (!still && d.autoplay !== undefined && d.autoplay !== 'false') {
        var timer = null;
        var loop = function () {
          if (state === 'idle' && !el.matches(':hover')) start(false);
          timer = setTimeout(loop, duration + hold + DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(loop, 700);
        }).observe(el);
      }
    }

    window.animacious = window.animacious || {};
    window.animacious.downloadProgress = function (el, p) { if (el && el._dpbProgress) el._dpbProgress(p); };

    function init() {
      document.querySelectorAll('[data-download-progress-button]').forEach(function (el) {
        if (el._dpb) return;
        el._dpb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button id="dl" data-download-progress-button>Download</button>

<!-- reporting real progress: -->
<button id="dl" data-download-progress-button data-manual>Download</button>
<script>
  dl.addEventListener("download", async () => {
    // … as data arrives:
    animacious.downloadProgress(dl, received / total);
  });
</script>