← Forms

File Upload Progress

Files upload one under another, each with its own bar and percentage, at uneven speeds like a real connection. As each one finishes its bar turns green and folds away, the file size slides in and a tick pops into a circle.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-file-upload-progress.
  3. For a demo, list pretend files in data-files. For real uploads, add a row per file from your upload code and report its progress (see below).

Options

Add any of these attributes to the same element.

data-filesPretend files that upload by themselves, as name:size pairs, comma-separated.
data-loopStart the pretend uploads again once they’ve all finished.
data-speedPretend upload speed in MB per second. Default 1.6.
data-colorBar colour while uploading. Default #6a0dad.

var row = animacious.fileUploadProgress.add(el, file.name, file.size) adds a row. Then call row.progress(0.4) (0 to 1) as it goes, row.done() at the end, or row.error('Too big') for a red cross and a shake. The file badge is coloured by its extension.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // File Upload Progress: a list of files uploading, each with its own bar
    // and percentage. When a file finishes, its bar turns green and folds
    // away, the size slides in and a tick pops into a circle.
    // Usage (demo): <div data-file-upload-progress data-files="Brief.pdf:2.4MB, Photo.jpg:3.1MB"></div>
    //   The files upload at made-up speeds, so you can see the effect.
    // Usage (real): <div data-file-upload-progress></div>, then drive it
    //   from your upload code (see Script use).
    // Optional: data-files (pretend files, "name:size" comma-separated),
    // data-loop (start the pretend uploads again after they all finish),
    // data-color (bar colour, default #6a0dad), data-speed (pretend upload
    // speed in MB per second, default 1.6).
    // Script use: var f = animacious.fileUploadProgress.add(el, file.name, file.size);
    //   f.progress(0.4) (0 to 1), f.done(), f.error('Upload failed'), f.remove().
    var DEFAULTS = { color: '#6a0dad', speed: 1.6 };

    var TICK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#16a34a"/>' +
      '<path d="M7 12.5l3.3 3.3L17 9" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
    var CROSS = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#dc2626"/>' +
      '<path d="M8.5 8.5l7 7M15.5 8.5l-7 7" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>';
    var KINDS = {
      pdf: '#e5484d', doc: '#3b82f6', docx: '#3b82f6', txt: '#71717a', jpg: '#0ea5a4', jpeg: '#0ea5a4', png: '#0ea5a4', gif: '#0ea5a4',
      svg: '#f59e0b', mp4: '#8b5cf6', mov: '#8b5cf6', mp3: '#ec4899', zip: '#f59e0b', csv: '#16a34a', xls: '#16a34a', xlsx: '#16a34a', ppt: '#f97316', pptx: '#f97316'
    };

    var css =
      '.fup{display:grid;gap:.6em;margin:0;padding:0;list-style:none;text-align:left}' +
      '.fup-item{display:flex;align-items:center;gap:.75em;box-sizing:border-box;padding:.7em .85em;border-radius:.8em;background:#fff;' +
      'box-shadow:0 0 0 1px rgba(0,0,0,.07),0 4px 14px rgba(15,15,30,.05);overflow:hidden}' +
      '.fup-kind{flex:none;display:grid;place-items:center;width:3.3em;height:3.7em;border-radius:.55em;color:#fff;font-size:.7em;font-weight:700;letter-spacing:0;text-transform:uppercase}' +
      '.fup-main{display:block;flex:1;min-width:0}' +
      '.fup-row{display:flex;align-items:baseline;gap:.6em}' +
      '.fup-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;color:#27272a}' +
      '.fup-pct{flex:none;font-size:.85em;color:#71717a;font-variant-numeric:tabular-nums}' +
      '.fup-sub{display:block;position:relative;height:1.35em;margin-top:.3em}' +
      '.fup-bar{display:block;position:absolute;left:0;right:0;top:.45em;height:.38em;border-radius:1em;background:#ececf0;overflow:hidden}' +
      '.fup-fill{display:block;height:100%;width:100%;border-radius:inherit;background:var(--fup-color);transform-origin:0 50%;transform:scaleX(0);transition:background-color .3s}' +
      '.fup-ok .fup-fill{background:#16a34a}.fup-fail .fup-fill{background:#dc2626}' +
      '.fup-size{position:absolute;left:0;top:0;font-size:.85em;line-height:1.6;color:#71717a;opacity:0;white-space:nowrap}' +
      '.fup-fail .fup-size{color:#dc2626}' +
      '.fup-end{flex:none;width:1.5em;height:1.5em;display:grid}' +
      '.fup-end svg{grid-area:1/1;width:100%;height:100%;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function size(n) {
      if (n < 1024) return n + ' B';
      if (n < 1048576) return Math.round(n / 1024) + ' KB';
      return (n / 1048576).toFixed(1) + ' MB';
    }
    function bytes(s) {
      var m = /([\d.]+)\s*(b|kb|mb|gb)?/i.exec(s || '');
      if (!m) return 1048576;
      var u = (m[2] || 'mb').toLowerCase();
      return Math.round(parseFloat(m[1]) * (u === 'gb' ? 1073741824 : u === 'mb' ? 1048576 : u === 'kb' ? 1024 : 1));
    }

    function list(el) {
      if (el._fupList) return el._fupList;
      el.classList.add('fup');
      el.style.setProperty('--fup-color', el.dataset.color || DEFAULTS.color);
      if (!el.closest('[aria-hidden="true"]')) { el.setAttribute('role', 'list'); el.setAttribute('aria-live', 'polite'); }
      el._fupList = el;
      return el;
    }

    // Adds one file row and returns its controls.
    function add(el, name, total) {
      list(el);
      var ext = (/\.([a-z0-9]+)$/i.exec(name) || [])[1] || 'file';
      ext = ext.toLowerCase();
      var row = document.createElement('div');
      row.className = 'fup-item';
      row.setAttribute('role', 'listitem');
      row.innerHTML = '<span class="fup-kind" aria-hidden="true"></span><span class="fup-main"><span class="fup-row"><span class="fup-name"></span>' +
        '<span class="fup-pct">0%</span></span><span class="fup-sub"><span class="fup-bar"><span class="fup-fill"></span></span><span class="fup-size"></span></span></span>' +
        '<span class="fup-end" aria-hidden="true">' + TICK + CROSS + '</span>';
      var kind = row.firstChild;
      kind.textContent = ext.slice(0, 4);
      kind.style.background = KINDS[ext] || '#a1a1aa';
      var nameEl = row.querySelector('.fup-name'), pct = row.querySelector('.fup-pct');
      var bar = row.querySelector('.fup-bar'), fillEl = row.querySelector('.fup-fill'), sizeEl = row.querySelector('.fup-size');
      var tick = row.querySelector('.fup-end').firstChild, cross = row.querySelector('.fup-end').lastChild;
      nameEl.textContent = name;
      el.appendChild(row);
      if (!reduced()) anime.animate(row, { opacity: [0, 1], y: [12, 0], duration: 420, ease: 'out(3)' });

      var finished = false;
      function end(ok, message) {
        if (finished) return;
        finished = true;
        row.classList.add(ok ? 'fup-ok' : 'fup-fail');
        if (ok) anime.utils.set(fillEl, { scaleX: 1 });
        pct.textContent = ok ? 'Done' : 'Failed';
        sizeEl.textContent = ok ? size(total) : (message || 'Upload failed');
        var mark = ok ? tick : cross;
        if (reduced()) {
          anime.utils.set(bar, { opacity: 0 });
          anime.utils.set(sizeEl, { opacity: 1 });
          anime.utils.set(mark, { opacity: 1 });
          return;
        }
        anime.animate(bar, { opacity: 0, scaleY: 0.2, duration: 300, delay: 280, ease: 'in(2)' });
        anime.animate(sizeEl, { opacity: [0, 1], y: [8, 0], duration: 360, delay: 500, ease: 'out(3)' });
        anime.animate(mark, { opacity: [0, 1], scale: [0, 1], rotate: ok ? [-60, 0] : [0, 0], duration: 560, delay: 200, ease: 'outBack(2.6)' });
        if (!ok) anime.animate(row, { x: [0, -6, 5, -3, 0], duration: 420, delay: 160, ease: 'out(2)' });
      }
      return {
        row: row,
        progress: function (p) {
          if (finished) return;
          p = Math.max(0, Math.min(1, p));
          anime.utils.set(fillEl, { scaleX: p });
          pct.textContent = Math.floor(p * 100) + '%';
        },
        done: function () { end(true); },
        error: function (message) { end(false, message); },
        remove: function () {
          if (reduced()) { row.remove(); return; }
          anime.animate(row, { opacity: 0, x: 20, duration: 260, ease: 'in(2)', onComplete: function () { row.remove(); } });
        }
      };
    }

    // The pretend uploads: uneven speeds with the odd stall, like a real network.
    function demo(el) {
      var d = el.dataset;
      var files = d.files.split(',').map(function (s) {
        var parts = s.trim().split(':');
        return { name: parts[0].trim(), bytes: bytes(parts[1]) };
      }).filter(function (f) { return f.name; });
      var speed = (parseFloat(d.speed) || DEFAULTS.speed) * 1048576;
      var items = [], raf = 0, last = 0, visible = false, holdUntil = 0;

      function start() {
        el.textContent = '';
        items = files.map(function (f, i) {
          return { f: f, ctl: add(el, f.name, f.bytes), sent: 0, wait: i * 350, rate: 0.6 + Math.random() * 0.8, next: 0, over: false };
        });
        holdUntil = 0;
      }
      function frame(now) {
        raf = 0;
        if (!visible) return;
        var dt = Math.min(64, now - (last || now));
        last = now;
        var busy = false;
        items.forEach(function (it) {
          if (it.over) return;
          busy = true;
          if (it.wait > 0) { it.wait -= dt; return; }
          if (now > it.next) { it.rate = Math.random() < 0.15 ? 0.08 : 0.5 + Math.random() * 1.1; it.next = now + 250 + Math.random() * 400; }
          // Small files still take a moment, big ones don't take forever.
          var t = Math.min(3.5, Math.max(1.4, it.f.bytes / speed));
          it.sent = Math.min(it.f.bytes, it.sent + it.f.bytes / t * it.rate * dt / 1000);
          it.ctl.progress(it.sent / it.f.bytes);
          if (it.sent >= it.f.bytes) { it.over = true; it.ctl.done(); }
        });
        if (!busy) {
          if (!on(d.loop)) return;
          if (!holdUntil) holdUntil = now + 2600;
          if (now > holdUntil) {
            holdUntil = 0;
            var rows = Array.prototype.slice.call(el.children);
            anime.animate(rows, {
              opacity: 0, y: -8, duration: 320, delay: anime.stagger(60), ease: 'in(2)',
              onComplete: function () { if (visible) { start(); if (!raf) raf = requestAnimationFrame(frame); } }
            });
            return;
          }
        }
        raf = requestAnimationFrame(frame);
      }
      list(el);
      if (reduced()) {
        start();
        items.forEach(function (it) { it.ctl.done(); });
        return;
      }
      var begun = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && !begun) { begun = true; start(); }
        if (visible && !raf) { last = 0; raf = requestAnimationFrame(frame); }
      }, { threshold: 0.2 }).observe(el);
    }

    window.animacious = window.animacious || {};
    window.animacious.fileUploadProgress = { add: add };

    function init() {
      document.querySelectorAll('[data-file-upload-progress]').forEach(function (el) {
        if (el._fup) return;
        el._fup = true;
        list(el);
        if (el.dataset.files) demo(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-file-upload-progress id="uploads"></div>

<script>
  function upload(file) {
    var row = animacious.fileUploadProgress.add(document.getElementById('uploads'), file.name, file.size);
    var xhr = new XMLHttpRequest();
    xhr.upload.onprogress = function (e) { row.progress(e.loaded / e.total); };
    xhr.onload = function () { xhr.status < 300 ? row.done() : row.error('Upload failed'); };
    xhr.open('POST', '/upload');
    xhr.send(file);
  }
</script>