Open demo

CSS code

HTML
<div><span class="lbl">Queue with progress bars and retry</span><div class="card">
<div class="q" data-q>
  <div class="q-head"><p class="q-title" id="q-docs-title">Tender documents</p><p class="q-summary" data-q-summary>No uploads yet</p></div>
  <div class="q-zone">
    <input class="q-input" type="file" id="q-docs" multiple accept=".pdf,.docx,.xlsx,.pptx,.zip"
      data-max-size="26214400" data-types="PDF, Word, Excel, PowerPoint or ZIP" aria-describedby="q-docs-hint q-docs-error">
    <div class="q-face"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 13v8"/><path d="M4 14.9A7 7 0 1 1 15.7 8h1.8a4.5 4.5 0 0 1 2.5 8.2"/><path d="m8 17 4-4 4 4"/></svg>
      <label for="q-docs">Drop files or <b>browse</b></label></div>
  </div>
  <p class="q-summary" id="q-docs-hint">PDF, Word, Excel, PowerPoint or ZIP, up to 25 MB each. Uploads start straight away.</p>
  <p class="q-error" id="q-docs-error" hidden></p>
  <ul class="q-list" aria-labelledby="q-docs-title" data-q-list></ul>
  <p class="q-sr" aria-live="polite" data-q-live></p>
  <template><li class="q-item" data-status="uploading"><span class="q-type" aria-hidden="true" data-ext></span><div class="q-body"><span class="q-name" data-name></span><progress class="q-bar" max="100" value="0" data-bar></progress><span class="q-status" data-text></span></div>
    <button class="q-act" type="button" data-act="cancel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
    <button class="q-act" type="button" data-act="retry"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>Retry</button>
    <button class="q-act" type="button" data-act="remove"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button></li></template>
</div></div></div>

<div><span class="lbl">Floating upload panel</span><div class="card">
<div class="q" data-q>
  <div>
    <input class="q-sr" type="file" id="q-media" multiple data-max-size="104857600" aria-describedby="q-media-hint q-media-error">
    <label class="q-upload" for="q-media"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3v12M7 8l5-5 5 5"/><path d="M5 21h14"/></svg>Upload files</label>
  </div>
  <p class="q-summary" id="q-media-hint">Any file type, up to 100 MB each.</p>
  <p class="q-error" id="q-media-error" hidden></p>
  <details class="qp" open>
    <summary class="qp-head"><span class="q-summary" data-q-summary>No uploads yet</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg></summary>
    <ul class="q-list" aria-label="Uploads" data-q-list></ul>
  </details>
  <p class="q-sr" aria-live="polite" data-q-live></p>
  <template><li class="q-item" data-status="uploading"><span class="q-ring" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-bar><svg viewBox="0 0 36 36" aria-hidden="true" focusable="false"><circle class="track" cx="18" cy="18" r="15"/><circle class="fill" cx="18" cy="18" r="15" pathLength="100"/></svg><i class="ok" aria-hidden="true">✓</i><i class="bad" aria-hidden="true">!</i></span><div class="q-body"><span class="q-name" data-name></span><span class="q-status" data-text></span></div>
    <button class="q-act" type="button" data-act="cancel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
    <button class="q-act" type="button" data-act="retry"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>Retry</button>
    <button class="q-act" type="button" data-act="remove"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button></li></template>
</div></div></div>
CSS
/* Upload queue. Each file gets a row whose data-status (uploading, done, error) drives the colours and which action
   button shows: Cancel, Remove or Retry. The upload is simulated; swap simulate() for XMLHttpRequest upload.onprogress. */
.q{--q-accent:#3b5bdb;--q-accent-ink:#2f4ac0;--q-tint:#eef2ff;--q-ink:#0f172a;--q-muted:#5b6474;--q-line:#8391a7;
  --q-ok:#15803d;--q-bad:#b42318;display:grid;gap:14px;color:var(--q-ink)}
.q-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.q-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.q-title{margin:0;font-size:1rem;font-weight:700}
.q-summary{margin:0;font-size:.85rem;color:var(--q-muted)}
.q-zone{position:relative}
.q-input{position:absolute;inset:0;z-index:1;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
.q-input::file-selector-button{cursor:pointer}
.q-face{display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;font-size:.92rem;background:#f8faff;
  border:2px dashed var(--q-line);border-radius:12px;transition:border-color .15s,background-color .15s}
.q-face svg{width:22px;height:22px;color:var(--q-accent)}
.q-face b{color:var(--q-accent-ink)}
.q-input:hover + .q-face,[data-drag] > .q-face{border-color:var(--q-accent);background:var(--q-tint)}
.q-input:focus-visible + .q-face{border-color:var(--q-accent);outline:3px solid var(--q-accent);outline-offset:3px}
.q-error{margin:0;padding:10px 12px;font-size:.85rem;color:var(--q-bad);background:#fef3f2;border:1px solid #fecdca;border-radius:10px}
.q-list{display:grid;gap:10px;margin:0;padding:0;list-style:none}
.q-list:empty{display:none}
.q-item{display:flex;align-items:center;gap:14px;padding:12px 12px 12px 14px;background:#fff;border:1px solid #e2e8f0;border-radius:12px}
.q-item[data-status="error"]{border-color:#fecdca;background:#fffbfa}
.q-type{display:grid;place-items:center;flex:none;width:40px;height:40px;border-radius:10px;font-size:.66rem;font-weight:800;letter-spacing:.04em;
  color:var(--q-accent-ink);background:var(--q-tint)}
.q-body{display:grid;gap:5px;flex:1;min-width:0}
.q-name{overflow:hidden;font-size:.9rem;font-weight:600;text-overflow:ellipsis;white-space:nowrap}
.q-bar{display:block;width:100%;height:6px;-webkit-appearance:none;appearance:none;border:0;border-radius:999px;background:#e2e8f0;overflow:hidden}
.q-bar::-webkit-progress-bar{background:#e2e8f0;border-radius:999px}
.q-bar::-webkit-progress-value{background:var(--q-accent);border-radius:999px;transition:width .2s}
.q-bar::-moz-progress-bar{background:var(--q-accent);border-radius:999px}
[data-status="done"] .q-bar::-webkit-progress-value{background:var(--q-ok)}
[data-status="done"] .q-bar::-moz-progress-bar{background:var(--q-ok)}
[data-status="error"] .q-bar::-webkit-progress-value{background:var(--q-bad)}
[data-status="error"] .q-bar::-moz-progress-bar{background:var(--q-bad)}
.q-status{display:flex;align-items:center;gap:6px;font-size:.8rem;color:var(--q-muted);font-variant-numeric:tabular-nums}
[data-status="done"] .q-status{color:var(--q-ok);font-weight:600}
[data-status="error"] .q-status{color:var(--q-bad);font-weight:600}
.q-act{display:none;align-items:center;justify-content:center;gap:6px;flex:none;min-width:34px;height:34px;padding:0 8px;font:inherit;font-size:.82rem;font-weight:600;
  color:#475467;background:none;border:0;border-radius:9px;cursor:pointer}
.q-act svg{width:18px;height:18px}
.q-act:hover{background:#f1f5f9;color:var(--q-ink)}
.q-act:focus-visible{outline:3px solid var(--q-accent);outline-offset:1px}
[data-status="uploading"] [data-act="cancel"],[data-status="done"] [data-act="remove"],[data-status="error"] [data-act="retry"]{display:inline-flex}
.q-act[data-act="retry"]{color:#fff;background:var(--q-bad);padding:0 12px}
.q-act[data-act="retry"]:hover{background:#912018;color:#fff}
/* floating panel */
.qp{overflow:hidden;background:#fff;border:1px solid #d5dbe5;border-radius:14px;box-shadow:0 18px 40px -18px rgba(15,23,42,.35)}
.qp-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;color:#fff;background:#0f172a;cursor:pointer;list-style:none}
.qp-head::-webkit-details-marker{display:none}
.qp-head:focus-visible{outline:3px solid var(--q-accent);outline-offset:-3px}
.qp-head .q-summary{color:#fff;font-weight:600}
.qp-head svg{width:18px;height:18px;flex:none;transition:transform .2s}
.qp:not([open]) .qp-head svg{transform:rotate(180deg)}
.qp .q-list{gap:0;padding:4px 0}
.qp .q-item{gap:12px;padding:9px 10px 9px 16px;border:0;border-radius:0}
.qp .q-item + .q-item{border-top:1px solid #eef1f6}
.qp .q-body{gap:0}
.q-ring{position:relative;display:grid;place-items:center;flex:none;width:30px;height:30px;color:var(--q-accent)}
.q-ring svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
.q-ring circle{fill:none;stroke-width:3.5}
.q-ring .track{stroke:#e2e8f0}
.q-ring .fill{stroke:currentColor;stroke-linecap:round;stroke-dasharray:calc(var(--p, 0) * 1) 100;transition:stroke-dasharray .2s}
.q-ring i{display:none;font-style:normal;font-size:.8rem;font-weight:800}
[data-status="done"] .q-ring{color:var(--q-ok)}
[data-status="error"] .q-ring{color:var(--q-bad)}
[data-status="done"] .q-ring .ok,[data-status="error"] .q-ring .bad{display:block}
.q-upload{display:inline-flex;align-items:center;gap:8px;padding:10px 16px;font-size:.9rem;font-weight:600;color:#fff;background:var(--q-accent);
  border-radius:10px;cursor:pointer;transition:background-color .15s}
.q-upload:hover{background:var(--q-accent-ink)}
.q-upload svg{width:18px;height:18px}
.q-sr:focus-visible + .q-upload{outline:3px solid var(--q-accent);outline-offset:3px}
@media (prefers-reduced-motion:reduce){.q-face,.q-upload,.qp-head svg,.q-ring .fill{transition:none}.q-bar::-webkit-progress-value{transition:none}}
JavaScript
// Upload queue: every picked or dropped file becomes a row that uploads on its own. simulate() fakes the network
// (every third file fails once so Retry can be tried); replace it with an XMLHttpRequest and upload.onprogress.
document.querySelectorAll('[data-q]').forEach(q => {
  const input = q.querySelector('input[type="file"]');
  const list = q.querySelector('[data-q-list]');
  const tpl = q.querySelector('template');
  const live = q.querySelector('[data-q-live]');
  const error = document.getElementById(input.id + '-error');
  const maxSize = Number(input.dataset.maxSize) || Infinity;
  const accept = input.accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
  const fmt = b => b < 1048576 ? Math.round(b / 1024) + ' KB' : (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
  const typeOk = f => !accept.length || accept.some(a => f.name.toLowerCase().endsWith(a));
  const labels = { cancel: 'Cancel upload of ', retry: 'Retry ', remove: 'Remove ' };
  let added = 0;

  function summarise() {
    const rows = [...list.children], n = s => rows.filter(r => r.dataset.status === s).length;
    const up = n('uploading'), ok = n('done'), bad = n('error');
    q.querySelector('[data-q-summary]').textContent = !rows.length ? 'No uploads yet'
      : up ? 'Uploading ' + up + (up === 1 ? ' file' : ' files') + (ok ? ' · ' + ok + ' done' : '')
      : bad ? ok + ' uploaded, ' + bad + ' failed' : 'All ' + ok + (ok === 1 ? ' file' : ' files') + ' uploaded';
  }
  function show(row, status) {
    const f = row.file, pct = Math.round(row.loaded / f.size * 100) || 0, was = row.dataset.status;
    const hadFocus = row.contains(document.activeElement);
    const bar = row.querySelector('[data-bar]');
    row.dataset.status = status;
    row.style.setProperty('--p', status === 'done' ? 100 : pct);
    if (bar.tagName === 'PROGRESS') bar.value = status === 'done' ? 100 : pct; else bar.setAttribute('aria-valuenow', status === 'done' ? 100 : pct);
    row.querySelector('[data-text]').textContent = status === 'done' ? '✓ Uploaded · ' + fmt(f.size)
      : status === 'error' ? 'Upload failed: connection lost' : fmt(row.loaded) + ' of ' + fmt(f.size) + ' · ' + pct + '%';
    if (status !== was) {
      if (status === 'done') live.textContent = f.name + ' uploaded.';
      if (status === 'error') live.textContent = f.name + ' failed to upload. Retry is available.';
      if (hadFocus) row.querySelector('[data-act="' + { uploading: 'cancel', done: 'remove', error: 'retry' }[status] + '"]').focus();
    }
    summarise();
  }
  function simulate(row) {
    clearInterval(row.timer);
    row.loaded = 0;
    const failAt = row.failOnce ? row.file.size * .45 : Infinity;
    row.failOnce = false;
    show(row, 'uploading');
    row.timer = setInterval(() => {
      row.loaded = Math.min(row.file.size, row.loaded + 160000 + Math.random() * 140000);
      if (row.loaded >= failAt) { clearInterval(row.timer); row.loaded = failAt; show(row, 'error'); }
      else if (row.loaded >= row.file.size) { clearInterval(row.timer); show(row, 'done'); }
      else show(row, 'uploading');
    }, 150);
  }
  function drop(row, message) {
    clearInterval(row.timer);
    const rows = [...list.children], i = rows.indexOf(row);
    row.remove(); summarise();
    live.textContent = message;
    const next = list.children[Math.min(i, list.children.length - 1)];
    const btn = next && [...next.querySelectorAll('[data-act]')].find(b => b.offsetParent !== null);
    (btn || input).focus();
  }
  input.addEventListener('change', () => {
    const problems = [], names = [];
    for (const f of input.files) {
      if (!typeOk(f)) { problems.push(f.name + ' wasn’t added: only ' + input.dataset.types + ' files are accepted.'); continue; }
      if (f.size > maxSize) { problems.push(f.name + ' wasn’t added: it is ' + fmt(f.size) + ', the limit is ' + fmt(maxSize) + '.'); continue; }
      const row = tpl.content.firstElementChild.cloneNode(true);
      row.file = f; row.failOnce = ++added % 3 === 0;
      const ext = row.querySelector('[data-ext]');
      if (ext) ext.textContent = (f.name.split('.').pop() || 'file').slice(0, 4).toUpperCase();
      row.querySelector('[data-name]').textContent = f.name;
      row.querySelector('[data-bar]').setAttribute('aria-label', 'Upload progress, ' + f.name);
      row.querySelectorAll('[data-act]').forEach(b => {
        b.setAttribute('aria-label', labels[b.dataset.act] + f.name);
        b.addEventListener('click', () => b.dataset.act === 'retry' ? (simulate(row), live.textContent = 'Retrying ' + f.name + '.')
          : drop(row, (b.dataset.act === 'cancel' ? 'Cancelled ' : 'Removed ') + f.name + '.'));
      });
      list.append(row); names.push(f.name); simulate(row);
    }
    input.value = '';   // the queue owns the files now; clearing lets the same file be picked again
    error.textContent = problems.join(' ');
    error.hidden = !problems.length;
    live.textContent = (names.length ? 'Uploading ' + names.join(', ') + '. ' : '') + problems.join(' ');
  });
  input.addEventListener('dragenter', () => input.parentElement.setAttribute('data-drag', ''));
  ['dragleave', 'drop'].forEach(t => input.addEventListener(t, () => input.parentElement.removeAttribute('data-drag')));
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Queue with progress bars and retry</span><div class="card demo-card"><div class="card-body">
<div class="q d-grid gap-3" data-q>
  <div class="d-flex align-items-center justify-content-between flex-wrap" style="gap:12px"><p class="fw-bold mb-0" id="q-docs-title">Tender documents</p><p class="q-muted mb-0" data-q-summary>No uploads yet</p></div>
  <div class="position-relative">
    <input class="q-input position-absolute w-100 h-100 m-0 opacity-0" type="file" id="q-docs" multiple accept=".pdf,.docx,.xlsx,.pptx,.zip"
      data-max-size="26214400" data-types="PDF, Word, Excel, PowerPoint or ZIP" aria-describedby="q-docs-hint q-docs-error">
    <div class="q-face d-flex align-items-center justify-content-center gap-2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 13v8"/><path d="M4 14.9A7 7 0 1 1 15.7 8h1.8a4.5 4.5 0 0 1 2.5 8.2"/><path d="m8 17 4-4 4 4"/></svg>
      <label for="q-docs">Drop files or <b>browse</b></label></div>
  </div>
  <p class="q-muted mb-0" id="q-docs-hint">PDF, Word, Excel, PowerPoint or ZIP, up to 25 MB each. Uploads start straight away.</p>
  <p class="alert alert-danger mb-0" id="q-docs-error" hidden></p>
  <ul class="q-list list-unstyled d-grid mb-0" style="gap:10px" aria-labelledby="q-docs-title" data-q-list></ul>
  <p class="visually-hidden" aria-live="polite" data-q-live></p>
  <template><li class="q-item d-flex align-items-center gap-3 bg-white border rounded-3" data-status="uploading"><span class="q-type d-grid flex-shrink-0 rounded-3 align-items-center justify-content-center" aria-hidden="true" data-ext></span><div class="d-grid gap-1 flex-grow-1" style="min-width:0"><span class="q-name fw-semibold text-truncate" data-name></span><div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-bar><div class="progress-bar"></div></div><span class="q-status" data-text></span></div>
    <button class="q-act btn align-items-center justify-content-center gap-1 flex-shrink-0" type="button" data-act="cancel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
    <button class="q-act btn align-items-center justify-content-center gap-1 flex-shrink-0" type="button" data-act="retry"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>Retry</button>
    <button class="q-act btn align-items-center justify-content-center gap-1 flex-shrink-0" type="button" data-act="remove"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button></li></template>
</div></div></div></div>

<div><span class="lbl">Floating upload panel</span><div class="card demo-card"><div class="card-body">
<div class="q d-grid gap-3" data-q>
  <div>
    <input class="visually-hidden" type="file" id="q-media" multiple data-max-size="104857600" aria-describedby="q-media-hint q-media-error">
    <label class="q-upload btn btn-primary d-inline-flex align-items-center gap-2" for="q-media"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3v12M7 8l5-5 5 5"/><path d="M5 21h14"/></svg>Upload files</label>
  </div>
  <p class="q-muted mb-0" id="q-media-hint">Any file type, up to 100 MB each.</p>
  <p class="alert alert-danger mb-0" id="q-media-error" hidden></p>
  <details class="qp bg-white" open>
    <summary class="qp-head d-flex align-items-center justify-content-between gap-2 text-white"><span class="q-muted" data-q-summary>No uploads yet</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg></summary>
    <ul class="q-list list-unstyled py-1 mb-0" aria-label="Uploads" data-q-list></ul>
  </details>
  <p class="visually-hidden" aria-live="polite" data-q-live></p>
  <template><li class="q-item d-flex align-items-center gap-3 bg-white" data-status="uploading"><span class="q-ring d-grid flex-shrink-0 align-items-center justify-content-center" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-bar><svg class="w-100 h-100" viewBox="0 0 36 36" aria-hidden="true" focusable="false"><circle class="track" cx="18" cy="18" r="15"/><circle class="fill" cx="18" cy="18" r="15" pathLength="100"/></svg><i class="ok" aria-hidden="true">✓</i><i class="bad" aria-hidden="true">!</i></span><div class="d-grid flex-grow-1" style="min-width:0"><span class="q-name fw-semibold text-truncate" data-name></span><span class="q-status" data-text></span></div>
    <button class="q-act btn align-items-center justify-content-center gap-1 flex-shrink-0" type="button" data-act="cancel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
    <button class="q-act btn align-items-center justify-content-center gap-1 flex-shrink-0" type="button" data-act="retry"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>Retry</button>
    <button class="q-act btn align-items-center justify-content-center gap-1 flex-shrink-0" type="button" data-act="remove"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button></li></template>
</div></div></div></div>
CSS
/* Upload queue for Bootstrap 5: .progress/.progress-bar for each file, .btn for the actions, .alert for errors.
   data-status on each row (uploading, done, error) picks the colour and the action button that shows. */
.q{--q-accent:#3b5bdb;--q-accent-ink:#2f4ac0;--q-tint:#eef2ff;--q-line:#8391a7;--q-muted:#5b6474;--q-ok:#15803d;--q-bad:#b42318;color:#0f172a}
.q-muted{font-size:.85rem;color:var(--q-muted)}
.q-input{inset:0;z-index:1;cursor:pointer}
.q-input::file-selector-button{cursor:pointer}
.q-face{padding:16px;font-size:.92rem;background:#f8faff;border:2px dashed var(--q-line);border-radius:12px;transition:border-color .15s,background-color .15s}
.q-face svg{width:22px;height:22px;color:var(--q-accent)}
.q-face b{color:var(--q-accent-ink)}
.q-input:hover + .q-face,[data-drag] > .q-face{border-color:var(--q-accent);background:var(--q-tint)}
.q-input:focus-visible + .q-face{border-color:var(--q-accent);outline:3px solid var(--q-accent);outline-offset:3px}
.q .alert-danger{--bs-alert-color:#b42318;--bs-alert-bg:#fef3f2;--bs-alert-border-color:#fecdca;--bs-alert-border-radius:10px;
  --bs-alert-padding-x:12px;--bs-alert-padding-y:10px;font-size:.85rem}
.q-list:empty{display:none}
.q-item{padding:12px 12px 12px 14px;border-color:#e2e8f0!important}
.q-item[data-status="error"]{border-color:#fecdca!important;background:#fffbfa!important}
.q-type{width:40px;height:40px;font-size:.66rem;font-weight:800;letter-spacing:.04em;color:var(--q-accent-ink);background:var(--q-tint)}
.q-name{font-size:.9rem}
.q .progress{--bs-progress-height:6px;--bs-progress-bg:#e2e8f0;--bs-progress-border-radius:999px;--bs-progress-bar-bg:var(--q-accent);
  --bs-progress-bar-transition:width .2s ease}
.q .progress-bar{width:calc(var(--p, 0) * 1%);border-radius:999px}
[data-status="done"] .progress{--bs-progress-bar-bg:var(--q-ok)}
[data-status="error"] .progress{--bs-progress-bar-bg:var(--q-bad)}
.q-status{font-size:.8rem;color:var(--q-muted);font-variant-numeric:tabular-nums}
[data-status="done"] .q-status{color:var(--q-ok);font-weight:600}
[data-status="error"] .q-status{color:var(--q-bad);font-weight:600}
.q-act{display:none!important;--bs-btn-padding-x:8px;--bs-btn-padding-y:0;--bs-btn-font-size:.82rem;--bs-btn-font-weight:600;--bs-btn-border-radius:9px;
  --bs-btn-color:#475467;--bs-btn-hover-bg:#f1f5f9;--bs-btn-hover-color:#0f172a;--bs-btn-border-width:0;min-width:34px;height:34px}
.q-act svg{width:18px;height:18px}
.q-act:focus-visible{outline:3px solid var(--q-accent);outline-offset:1px;box-shadow:none}
[data-status="uploading"] [data-act="cancel"],[data-status="done"] [data-act="remove"],[data-status="error"] [data-act="retry"]{display:inline-flex!important}
.q-act[data-act="retry"]{--bs-btn-color:#fff;--bs-btn-bg:var(--q-bad);--bs-btn-hover-bg:#912018;--bs-btn-hover-color:#fff;--bs-btn-padding-x:12px}
.q-upload{--bs-btn-bg:var(--q-accent);--bs-btn-border-color:var(--q-accent);--bs-btn-hover-bg:var(--q-accent-ink);--bs-btn-hover-border-color:var(--q-accent-ink);
  --bs-btn-padding-x:16px;--bs-btn-padding-y:10px;--bs-btn-font-size:.9rem;--bs-btn-font-weight:600;--bs-btn-border-radius:10px}
.q-upload svg{width:18px;height:18px}
.visually-hidden:focus-visible + .q-upload{outline:3px solid var(--q-accent);outline-offset:3px}
.qp{overflow:hidden;border:1px solid #d5dbe5;border-radius:14px;box-shadow:0 18px 40px -18px rgba(15,23,42,.35)}
.qp-head{padding:12px 16px;background:#0f172a;cursor:pointer;list-style:none}
.qp-head::-webkit-details-marker{display:none}
.qp-head:focus-visible{outline:3px solid var(--q-accent);outline-offset:-3px}
.qp-head .q-muted{color:#fff;font-weight:600}
.qp-head svg{width:18px;height:18px;transition:transform .2s}
.qp:not([open]) .qp-head svg{transform:rotate(180deg)}
.qp .q-item{border:0!important;border-radius:0!important;padding:9px 10px 9px 16px!important}
.qp .q-item + .q-item{border-top:1px solid #eef1f6!important}
.q-ring{position:relative;width:30px;height:30px;color:var(--q-accent)}
.q-ring svg{position:absolute;inset:0;transform:rotate(-90deg)}
.q-ring circle{fill:none;stroke-width:3.5}
.q-ring .track{stroke:#e2e8f0}
.q-ring .fill{stroke:currentColor;stroke-linecap:round;stroke-dasharray:calc(var(--p, 0) * 1) 100;transition:stroke-dasharray .2s}
.q-ring i{display:none;font-style:normal;font-size:.8rem;font-weight:800}
[data-status="done"] .q-ring{color:var(--q-ok)}
[data-status="error"] .q-ring{color:var(--q-bad)}
[data-status="done"] .q-ring .ok,[data-status="error"] .q-ring .bad{display:block}
@media (prefers-reduced-motion:reduce){.q-face,.qp-head svg,.q-ring .fill,.q .progress-bar{transition:none}}
JavaScript
// Upload queue: every picked or dropped file becomes a row that uploads on its own. simulate() fakes the network
// (every third file fails once so Retry can be tried); replace it with an XMLHttpRequest and upload.onprogress.
document.querySelectorAll('[data-q]').forEach(q => {
  const input = q.querySelector('input[type="file"]');
  const list = q.querySelector('[data-q-list]');
  const tpl = q.querySelector('template');
  const live = q.querySelector('[data-q-live]');
  const error = document.getElementById(input.id + '-error');
  const maxSize = Number(input.dataset.maxSize) || Infinity;
  const accept = input.accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
  const fmt = b => b < 1048576 ? Math.round(b / 1024) + ' KB' : (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
  const typeOk = f => !accept.length || accept.some(a => f.name.toLowerCase().endsWith(a));
  const labels = { cancel: 'Cancel upload of ', retry: 'Retry ', remove: 'Remove ' };
  let added = 0;

  function summarise() {
    const rows = [...list.children], n = s => rows.filter(r => r.dataset.status === s).length;
    const up = n('uploading'), ok = n('done'), bad = n('error');
    q.querySelector('[data-q-summary]').textContent = !rows.length ? 'No uploads yet'
      : up ? 'Uploading ' + up + (up === 1 ? ' file' : ' files') + (ok ? ' · ' + ok + ' done' : '')
      : bad ? ok + ' uploaded, ' + bad + ' failed' : 'All ' + ok + (ok === 1 ? ' file' : ' files') + ' uploaded';
  }
  function show(row, status) {
    const f = row.file, pct = Math.round(row.loaded / f.size * 100) || 0, was = row.dataset.status;
    const hadFocus = row.contains(document.activeElement);
    const bar = row.querySelector('[data-bar]');
    row.dataset.status = status;
    row.style.setProperty('--p', status === 'done' ? 100 : pct);
    if (bar.tagName === 'PROGRESS') bar.value = status === 'done' ? 100 : pct; else bar.setAttribute('aria-valuenow', status === 'done' ? 100 : pct);
    row.querySelector('[data-text]').textContent = status === 'done' ? '✓ Uploaded · ' + fmt(f.size)
      : status === 'error' ? 'Upload failed: connection lost' : fmt(row.loaded) + ' of ' + fmt(f.size) + ' · ' + pct + '%';
    if (status !== was) {
      if (status === 'done') live.textContent = f.name + ' uploaded.';
      if (status === 'error') live.textContent = f.name + ' failed to upload. Retry is available.';
      if (hadFocus) row.querySelector('[data-act="' + { uploading: 'cancel', done: 'remove', error: 'retry' }[status] + '"]').focus();
    }
    summarise();
  }
  function simulate(row) {
    clearInterval(row.timer);
    row.loaded = 0;
    const failAt = row.failOnce ? row.file.size * .45 : Infinity;
    row.failOnce = false;
    show(row, 'uploading');
    row.timer = setInterval(() => {
      row.loaded = Math.min(row.file.size, row.loaded + 160000 + Math.random() * 140000);
      if (row.loaded >= failAt) { clearInterval(row.timer); row.loaded = failAt; show(row, 'error'); }
      else if (row.loaded >= row.file.size) { clearInterval(row.timer); show(row, 'done'); }
      else show(row, 'uploading');
    }, 150);
  }
  function drop(row, message) {
    clearInterval(row.timer);
    const rows = [...list.children], i = rows.indexOf(row);
    row.remove(); summarise();
    live.textContent = message;
    const next = list.children[Math.min(i, list.children.length - 1)];
    const btn = next && [...next.querySelectorAll('[data-act]')].find(b => b.offsetParent !== null);
    (btn || input).focus();
  }
  input.addEventListener('change', () => {
    const problems = [], names = [];
    for (const f of input.files) {
      if (!typeOk(f)) { problems.push(f.name + ' wasn’t added: only ' + input.dataset.types + ' files are accepted.'); continue; }
      if (f.size > maxSize) { problems.push(f.name + ' wasn’t added: it is ' + fmt(f.size) + ', the limit is ' + fmt(maxSize) + '.'); continue; }
      const row = tpl.content.firstElementChild.cloneNode(true);
      row.file = f; row.failOnce = ++added % 3 === 0;
      const ext = row.querySelector('[data-ext]');
      if (ext) ext.textContent = (f.name.split('.').pop() || 'file').slice(0, 4).toUpperCase();
      row.querySelector('[data-name]').textContent = f.name;
      row.querySelector('[data-bar]').setAttribute('aria-label', 'Upload progress, ' + f.name);
      row.querySelectorAll('[data-act]').forEach(b => {
        b.setAttribute('aria-label', labels[b.dataset.act] + f.name);
        b.addEventListener('click', () => b.dataset.act === 'retry' ? (simulate(row), live.textContent = 'Retrying ' + f.name + '.')
          : drop(row, (b.dataset.act === 'cancel' ? 'Cancelled ' : 'Removed ') + f.name + '.'));
      });
      list.append(row); names.push(f.name); simulate(row);
    }
    input.value = '';   // the queue owns the files now; clearing lets the same file be picked again
    error.textContent = problems.join(' ');
    error.hidden = !problems.length;
    live.textContent = (names.length ? 'Uploading ' + names.join(', ') + '. ' : '') + problems.join(' ');
  });
  input.addEventListener('dragenter', () => input.parentElement.setAttribute('data-drag', ''));
  ['dragleave', 'drop'].forEach(t => input.addEventListener(t, () => input.parentElement.removeAttribute('data-drag')));
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-q-muted uppercase">Queue with progress bars and retry</span><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,.05)] max-sm:p-[18px]">
<div class="grid gap-3.5" data-q>
  <div class="flex flex-wrap items-center justify-between gap-3"><p class="font-bold" id="q-docs-title">Tender documents</p><p class="text-[.85rem] text-q-muted" data-q-summary>No uploads yet</p></div>
  <div class="group/zone relative">
    <input class="peer absolute inset-0 z-[1] m-0 size-full cursor-pointer opacity-0 file:cursor-pointer" type="file" id="q-docs" multiple accept=".pdf,.docx,.xlsx,.pptx,.zip"
      data-max-size="26214400" data-types="PDF, Word, Excel, PowerPoint or ZIP" aria-describedby="q-docs-hint q-docs-error">
    <div class="flex items-center justify-center gap-2.5 rounded-xl border-2 border-dashed border-q-line bg-[#f8faff] p-4 text-[.92rem] transition-[border-color,background-color] peer-hover:border-q-accent peer-hover:bg-q-tint peer-focus-visible:border-q-accent peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-q-accent group-data-[drag]/zone:border-q-accent group-data-[drag]/zone:bg-q-tint motion-reduce:transition-none"><svg class="size-[22px] text-q-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 13v8"/><path d="M4 14.9A7 7 0 1 1 15.7 8h1.8a4.5 4.5 0 0 1 2.5 8.2"/><path d="m8 17 4-4 4 4"/></svg>
      <label for="q-docs">Drop files or <b class="text-q-accent-ink">browse</b></label></div>
  </div>
  <p class="text-[.85rem] text-q-muted" id="q-docs-hint">PDF, Word, Excel, PowerPoint or ZIP, up to 25 MB each. Uploads start straight away.</p>
  <p class="rounded-[10px] border border-[#fecdca] bg-[#fef3f2] px-3 py-2.5 text-[.85rem] text-q-bad" id="q-docs-error" hidden></p>
  <ul class="grid gap-2.5 empty:hidden" aria-labelledby="q-docs-title" data-q-list></ul>
  <p class="sr-only" aria-live="polite" data-q-live></p>
  <template><li class="group flex items-center gap-3.5 rounded-xl border border-slate-200 bg-white py-3 pr-3 pl-3.5 data-[status=error]:border-[#fecdca] data-[status=error]:bg-[#fffbfa]" data-status="uploading"><span class="grid size-10 flex-none place-items-center rounded-[10px] bg-q-tint text-[.66rem] font-extrabold tracking-[.04em] text-q-accent-ink" aria-hidden="true" data-ext></span><div class="grid min-w-0 flex-1 gap-[5px]"><span class="truncate text-[.9rem] font-semibold" data-name></span><progress class="q-bar group-data-[status=done]:[--q-fill:var(--color-q-ok)] group-data-[status=error]:[--q-fill:var(--color-q-bad)]" max="100" value="0" data-bar></progress><span class="flex items-center gap-1.5 text-[.8rem] text-q-muted tabular-nums group-data-[status=done]:font-semibold group-data-[status=done]:text-q-ok group-data-[status=error]:font-semibold group-data-[status=error]:text-q-bad" data-text></span></div>
    <button class="hidden h-[34px] min-w-[34px] flex-none cursor-pointer items-center justify-center gap-1.5 rounded-[9px] px-2 text-[.82rem] font-semibold text-[#475467] group-data-[status=uploading]:inline-flex hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-q-accent" type="button" data-act="cancel"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
    <button class="hidden h-[34px] min-w-[34px] flex-none cursor-pointer items-center justify-center gap-1.5 rounded-[9px] bg-q-bad px-3 text-[.82rem] font-semibold text-white group-data-[status=error]:inline-flex hover:bg-[#912018] focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-q-accent" type="button" data-act="retry"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>Retry</button>
    <button class="hidden h-[34px] min-w-[34px] flex-none cursor-pointer items-center justify-center gap-1.5 rounded-[9px] px-2 text-[.82rem] font-semibold text-[#475467] group-data-[status=done]:inline-flex hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-q-accent" type="button" data-act="remove"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button></li></template>
</div></div></div>

<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-q-muted uppercase">Floating upload panel</span><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,.05)] max-sm:p-[18px]">
<div class="grid gap-3.5" data-q>
  <div>
    <input class="peer sr-only" type="file" id="q-media" multiple data-max-size="104857600" aria-describedby="q-media-hint q-media-error">
    <label class="inline-flex cursor-pointer items-center gap-2 rounded-[10px] bg-q-accent px-4 py-2.5 text-[.9rem] font-semibold text-white transition-[background-color] peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-q-accent hover:bg-q-accent-ink motion-reduce:transition-none" for="q-media"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3v12M7 8l5-5 5 5"/><path d="M5 21h14"/></svg>Upload files</label>
  </div>
  <p class="text-[.85rem] text-q-muted" id="q-media-hint">Any file type, up to 100 MB each.</p>
  <p class="rounded-[10px] border border-[#fecdca] bg-[#fef3f2] px-3 py-2.5 text-[.85rem] text-q-bad" id="q-media-error" hidden></p>
  <details class="group/qp overflow-hidden rounded-[14px] border border-[#d5dbe5] bg-white shadow-[0_18px_40px_-18px_rgba(15,23,42,.35)]" open>
    <summary class="flex cursor-pointer list-none items-center justify-between gap-2.5 bg-slate-900 px-4 py-3 text-white focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-q-accent [&::-webkit-details-marker]:hidden"><span class="text-[.85rem] font-semibold" data-q-summary>No uploads yet</span><svg class="size-[18px] flex-none rotate-180 transition-transform duration-200 group-open/qp:rotate-0 motion-reduce:transition-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg></summary>
    <ul class="grid py-1 empty:hidden" aria-label="Uploads" data-q-list></ul>
  </details>
  <p class="sr-only" aria-live="polite" data-q-live></p>
  <template><li class="group flex items-center gap-3 border-slate-100 py-[9px] pr-2.5 pl-4 not-first:border-t" data-status="uploading"><span class="relative grid size-[30px] flex-none place-items-center text-q-accent group-data-[status=done]:text-q-ok group-data-[status=error]:text-q-bad" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-bar><svg class="absolute inset-0 size-full -rotate-90 fill-none" viewBox="0 0 36 36" aria-hidden="true" focusable="false"><circle class="stroke-slate-200 stroke-[3.5]" cx="18" cy="18" r="15"/><circle class="stroke-current stroke-[3.5] [stroke-dasharray:calc(var(--p,0)*1)_100] [stroke-linecap:round] transition-[stroke-dasharray] duration-200 motion-reduce:transition-none" cx="18" cy="18" r="15" pathLength="100"/></svg><i class="hidden text-[.8rem] font-extrabold not-italic group-data-[status=done]:block" aria-hidden="true">✓</i><i class="hidden text-[.8rem] font-extrabold not-italic group-data-[status=error]:block" aria-hidden="true">!</i></span><div class="grid min-w-0 flex-1"><span class="truncate text-[.9rem] font-semibold" data-name></span><span class="text-[.8rem] text-q-muted tabular-nums group-data-[status=done]:font-semibold group-data-[status=done]:text-q-ok group-data-[status=error]:font-semibold group-data-[status=error]:text-q-bad" data-text></span></div>
    <button class="hidden h-[34px] min-w-[34px] flex-none cursor-pointer items-center justify-center gap-1.5 rounded-[9px] px-2 text-[.82rem] font-semibold text-[#475467] group-data-[status=uploading]:inline-flex hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-q-accent" type="button" data-act="cancel"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
    <button class="hidden h-[34px] min-w-[34px] flex-none cursor-pointer items-center justify-center gap-1.5 rounded-[9px] bg-q-bad px-3 text-[.82rem] font-semibold text-white group-data-[status=error]:inline-flex hover:bg-[#912018] focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-q-accent" type="button" data-act="retry"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>Retry</button>
    <button class="hidden h-[34px] min-w-[34px] flex-none cursor-pointer items-center justify-center gap-1.5 rounded-[9px] px-2 text-[.82rem] font-semibold text-[#475467] group-data-[status=done]:inline-flex hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-q-accent" type="button" data-act="remove"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button></li></template>
</div></div></div>
CSS
@theme {
  --color-q-accent: #3b5bdb;
  --color-q-accent-ink: #2f4ac0;
  --color-q-tint: #eef2ff;
  --color-q-line: #8391a7;
  --color-q-muted: #5b6474;
  --color-q-ok: #15803d;
  --color-q-bad: #b42318;
}
/* the native <progress> parts, coloured from the row's data-status */
@utility q-bar {
  display: block; width: 100%; height: 6px; appearance: none; border: 0; border-radius: 999px; background: #e2e8f0; overflow: hidden;
  &::-webkit-progress-bar { background: #e2e8f0; border-radius: 999px; }
  &::-webkit-progress-value { background: var(--q-fill, var(--color-q-accent)); border-radius: 999px; transition: width .2s; }
  &::-moz-progress-bar { background: var(--q-fill, var(--color-q-accent)); border-radius: 999px; }
  @media (prefers-reduced-motion: reduce) { &::-webkit-progress-value { transition: none; } }
}
JavaScript
// Upload queue: every picked or dropped file becomes a row that uploads on its own. simulate() fakes the network
// (every third file fails once so Retry can be tried); replace it with an XMLHttpRequest and upload.onprogress.
document.querySelectorAll('[data-q]').forEach(q => {
  const input = q.querySelector('input[type="file"]');
  const list = q.querySelector('[data-q-list]');
  const tpl = q.querySelector('template');
  const live = q.querySelector('[data-q-live]');
  const error = document.getElementById(input.id + '-error');
  const maxSize = Number(input.dataset.maxSize) || Infinity;
  const accept = input.accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
  const fmt = b => b < 1048576 ? Math.round(b / 1024) + ' KB' : (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
  const typeOk = f => !accept.length || accept.some(a => f.name.toLowerCase().endsWith(a));
  const labels = { cancel: 'Cancel upload of ', retry: 'Retry ', remove: 'Remove ' };
  let added = 0;

  function summarise() {
    const rows = [...list.children], n = s => rows.filter(r => r.dataset.status === s).length;
    const up = n('uploading'), ok = n('done'), bad = n('error');
    q.querySelector('[data-q-summary]').textContent = !rows.length ? 'No uploads yet'
      : up ? 'Uploading ' + up + (up === 1 ? ' file' : ' files') + (ok ? ' · ' + ok + ' done' : '')
      : bad ? ok + ' uploaded, ' + bad + ' failed' : 'All ' + ok + (ok === 1 ? ' file' : ' files') + ' uploaded';
  }
  function show(row, status) {
    const f = row.file, pct = Math.round(row.loaded / f.size * 100) || 0, was = row.dataset.status;
    const hadFocus = row.contains(document.activeElement);
    const bar = row.querySelector('[data-bar]');
    row.dataset.status = status;
    row.style.setProperty('--p', status === 'done' ? 100 : pct);
    if (bar.tagName === 'PROGRESS') bar.value = status === 'done' ? 100 : pct; else bar.setAttribute('aria-valuenow', status === 'done' ? 100 : pct);
    row.querySelector('[data-text]').textContent = status === 'done' ? '✓ Uploaded · ' + fmt(f.size)
      : status === 'error' ? 'Upload failed: connection lost' : fmt(row.loaded) + ' of ' + fmt(f.size) + ' · ' + pct + '%';
    if (status !== was) {
      if (status === 'done') live.textContent = f.name + ' uploaded.';
      if (status === 'error') live.textContent = f.name + ' failed to upload. Retry is available.';
      if (hadFocus) row.querySelector('[data-act="' + { uploading: 'cancel', done: 'remove', error: 'retry' }[status] + '"]').focus();
    }
    summarise();
  }
  function simulate(row) {
    clearInterval(row.timer);
    row.loaded = 0;
    const failAt = row.failOnce ? row.file.size * .45 : Infinity;
    row.failOnce = false;
    show(row, 'uploading');
    row.timer = setInterval(() => {
      row.loaded = Math.min(row.file.size, row.loaded + 160000 + Math.random() * 140000);
      if (row.loaded >= failAt) { clearInterval(row.timer); row.loaded = failAt; show(row, 'error'); }
      else if (row.loaded >= row.file.size) { clearInterval(row.timer); show(row, 'done'); }
      else show(row, 'uploading');
    }, 150);
  }
  function drop(row, message) {
    clearInterval(row.timer);
    const rows = [...list.children], i = rows.indexOf(row);
    row.remove(); summarise();
    live.textContent = message;
    const next = list.children[Math.min(i, list.children.length - 1)];
    const btn = next && [...next.querySelectorAll('[data-act]')].find(b => b.offsetParent !== null);
    (btn || input).focus();
  }
  input.addEventListener('change', () => {
    const problems = [], names = [];
    for (const f of input.files) {
      if (!typeOk(f)) { problems.push(f.name + ' wasn’t added: only ' + input.dataset.types + ' files are accepted.'); continue; }
      if (f.size > maxSize) { problems.push(f.name + ' wasn’t added: it is ' + fmt(f.size) + ', the limit is ' + fmt(maxSize) + '.'); continue; }
      const row = tpl.content.firstElementChild.cloneNode(true);
      row.file = f; row.failOnce = ++added % 3 === 0;
      const ext = row.querySelector('[data-ext]');
      if (ext) ext.textContent = (f.name.split('.').pop() || 'file').slice(0, 4).toUpperCase();
      row.querySelector('[data-name]').textContent = f.name;
      row.querySelector('[data-bar]').setAttribute('aria-label', 'Upload progress, ' + f.name);
      row.querySelectorAll('[data-act]').forEach(b => {
        b.setAttribute('aria-label', labels[b.dataset.act] + f.name);
        b.addEventListener('click', () => b.dataset.act === 'retry' ? (simulate(row), live.textContent = 'Retrying ' + f.name + '.')
          : drop(row, (b.dataset.act === 'cancel' ? 'Cancelled ' : 'Removed ') + f.name + '.'));
      });
      list.append(row); names.push(f.name); simulate(row);
    }
    input.value = '';   // the queue owns the files now; clearing lets the same file be picked again
    error.textContent = problems.join(' ');
    error.hidden = !problems.length;
    live.textContent = (names.length ? 'Uploading ' + names.join(', ') + '. ' : '') + problems.join(' ');
  });
  input.addEventListener('dragenter', () => input.parentElement.setAttribute('data-drag', ''));
  ['dragleave', 'drop'].forEach(t => input.addEventListener(t, () => input.parentElement.removeAttribute('data-drag')));
});

About this set

Upload queues that show every file’s progress and what went wrong. Queue with progress bars and retry puts a compact dropzone above a list of rows: each has a type badge, the file name, a progress bar, a live readout such as 2.2 MB of 6.5 MB, 34 percent, and one action that fits the state, Cancel while uploading, Remove when done and Retry when it failed. Finished rows turn green, failed rows turn red with a connection lost message, and the header sums up the queue. Floating upload panel is the corner panel familiar from cloud drives: an Upload files button, then a dark header that collapses the list through a native details element, with ring progress, a tick or an exclamation mark per file. Every row carries a data-status attribute, uploading, done or error, and CSS decides the colours and which button shows. The network is simulated so the states can be tried: every third file fails once and succeeds on retry. Replace the simulate function with XMLHttpRequest and upload.onprogress to send real files. The input is cleared after each pick, so the queue owns the files and the same file can be added again.

What’s included

  • Per-file progress with size and percentage
  • Uploading, done and error states from one data-status attribute
  • Cancel, Remove and Retry actions
  • Collapsible floating panel built on details and summary
  • Simulated upload that is easy to swap for XMLHttpRequest

Accessibility

Each progress bar is a native progress element or a progressbar role with a name that includes the file, and the status text is written out rather than shown only in colour. Action buttons are named after their file; when a state change hides the focused button, focus moves to the new one. Completed and failed uploads are announced in a polite live region without reading every percentage. Bar and ring animations switch off under prefers-reduced-motion.

Customise it

Set --q-accent, --q-ok and --q-bad on .q for the uploading, done and error colours, and data-max-size or accept on the input. Bootstrap drives .progress-bar width from the same --p value; Tailwind styles the native progress element in the q-bar @utility.