GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

File Upload Progress Bar Loader: 9 UI Demos

A file upload progress bar. Drop a file and bytes-over-time become a spinning ring, a speed readout, and a checkmark at the end.

Auto-plays · click a tile to jump to its section · all nine in one zip

Demo

Nine upload widgets, one pipeline: bytes in, math on those bytes, a number on screen. Every scene here computes what it shows — a ring's fill comes from a circumference measured off the live element, a KB/s readout from the byte delta divided by the time delta, a resume from the index of the last confirmed chunk — and the value you see only eases toward that computed target, frame by frame. A paste capture clears the widest footprint of the set at a measured 18.947 percent cumulative area (a 620 ms indeterminate ring followed by a thumbnail swap redraws nearly a fifth of the frame), while the success morph is the smallest at 1.049 percent because only the SVG fragment that turns from ring into checkmark ever moves. Intensity peaks on the speed label at 163.6; animated frames split 11 to 21 out of 24 across the set.

Because this is a demo page with no server behind it, six of the nine scenes boot in an is-demo state where a seeded generator (mulberry32) shakes a pretend byte stream through the same event bus a real XMLHttpRequest upload would fire into; the other three — dropzone highlight, thumbnail pop-in, paste capture — never compute a byte-derived percentage, so their preview is a plain CSS loop instead. Interact — click, focus, drop, paste — and the identical code path keeps running with your real File object. The drag and drop upload ui responds the moment a file crosses the border, the paste capture accepts a screenshot with no drag at all, and every retry resumes from the byte where the transfer broke rather than from zero.

From Drop Zone to Paste: Nine Steps of the Upload Journey

The sequence follows the life of one upload. 01 is the door: a dropzone whose highlight stays steady while the pointer crosses child elements, because an enter/leave counter cancels the duplicate events. 02 and 03 are the readouts — a circular ring whose arc is the circumference math, a speed label whose KB/s is recomputed from byte delta over time delta. 04 widens to many files at once, staggering the list rows by registration index. 05 previews the file itself the instant FileReader finishes reading it. 06 closes the loop: the frame that reaches 100 percent morphs the ring into a checkmark. 07 and 08 are the failure pair — an error shake whose retry remembers the loaded byte count, and a chunked transfer that pauses mid-file and re-fills from the last confirmed chunk. 09 removes the drag entirely: paste a screenshot and the same pipeline takes it. The order is also a difficulty ramp: each item adds exactly one API to the previous one's pipeline.

# Item Technique Painted area Peak frame Avg intensity
01 Dropzone highlight depth counter, badge 640 ms 13.246% 6.365% 55.6
02 Circular percent ring r=52, 2πr ≈ 326.7, lerp 0.18 1.533% 1.474% 121.8
03 Speed label 5-sample rolling average, lerp 0.3 3.254% 3.204% 163.6
04 Multi-file stagger index × 80 ms, 4 rows 11.172% 7.458% 76.7
05 Thumbnail pop-in reader.onload, 900 ms fill 4.903% 3.464% 145.3
06 Success morph getTotalLength(), lerp 0.2 1.049% 0.517% 74.6
07 Error shake, retry FAIL_AT 58, abort + run(loaded) 2.978% 1.521% 161.2
08 Chunk pause/resume Blob.slice, 6 × 256 KB 3.703% 3.667% 87.6
09 Paste capture clipboardData.items, 620 ms spin 18.947% 17.933% 93.4

Every item landing in a 1–19 percent painted-area band is the nature of this set. A component post moves a narrow UI fragment — a ring, a bar, a badge — not a full-canvas scene, so a small area is normal; the sophistication here sits in a layer of computation (recomputed byte-over-time speed, circumference math, chunk-resume indexing, drag counter cancellation, retry that resends only the missing bytes), not in area. On animated frames out of 24, item 07's error shake is highest at 21 because the value updates every 220 ms and the shake layers on top, while item 08's chunk resume is lowest at 11 — the 160–300 ms wait between chunks is long by design. Anyone building a file upload progress indicator with React will find the same nine states in the zip as React ports, and the vanilla fences below are the logic those ports share.

Code per item

Each fence below comes straight out of that item's own source — the real constants, the real identifiers — with the Korean comment translated. Nothing is trimmed with an ellipsis; the zip carries the nine full files.

01Dropzone highlight that never flickers over child elements

For file attach forms and dashboard upload cards, this is the door a file enters — dragenter and dragleave fire in duplicate pairs every time the pointer crosses a child node, so a single counter adds one on enter, subtracts one on leave, and the border lights only while the count is above zero.

Dropzone highlight that never flickers over child elements
Open in a new tab

01 · Dropzone highlight that never flickers over child elements — Measured area 13.246% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

dragenterdragleavedataTransfer
var depth = 0;

function paint() {
  zone.classList.toggle('is-over', depth > 0);
  badge.style.setProperty('--n', depth);
}
function enter(e) { e.preventDefault(); depth += 1; paint(); }
function leave(e) { e.preventDefault(); depth = Math.max(0, depth - 1); paint(); }
function over(e) { e.preventDefault(); }
function drop(e) {
  e.preventDefault();
  var fileCount = (e.dataTransfer && e.dataTransfer.files) ? e.dataTransfer.files.length : 0;
  depth = 0; paint();
  zone.setAttribute('data-drop-count', String(fileCount));
  zone.classList.add('is-dropped');
  setTimeout(function () { zone.classList.remove('is-dropped'); }, 640);
}

The counter is the whole trick

Without it, the border flickers off and back at every child boundary, and the clamp at zero keeps a stray leave from sticking the highlight on. On drop, the browser's own file count stamps a badge that reverts after 640 ms — the number shown is the number of files actually handed over.

02Circular percent ring computed from its own circumference

For profile photo uploads and single-file forms — the radius is measured with getBoundingClientRect, the circumference (2πr) goes into stroke-dasharray as a constant, and stroke-dashoffset winds back by the percent, so the ring and the numeric label always draw the same value.

Circular percent ring computed from its own circumference
Open in a new tab

02 · Circular percent ring computed from its own circumference — Measured area 1.533% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetgetBoundingClientRectrequestAnimationFrame
function paint() {
  shown += (target - shown) * 0.18;
  if (Math.abs(target - shown) < 0.05) shown = target;
  prog.style.setProperty('--p', shown.toFixed(2));
  pct.style.setProperty('--n', Math.round(shown));
  if (shown < 100) raf = requestAnimationFrame(paint);
}
function tick() {
  if (root.classList.contains('is-demo')) return;
  target = Math.min(100, target + 3 + rand() * 9);
  if (target >= 100) clearInterval(timer);
}
var timer = null;
function live() {
  if (!root.classList.contains('is-demo')) return;
  root.classList.remove('is-demo');
  timer = setInterval(tick, 220);
  raf = requestAnimationFrame(paint);
}

Why the label never disagrees with the arc

Both read one variable, eased by 18 percent of the remaining distance per frame and written to the arc and the rounded label in a single pass; the target jitters 3–12 points per 220 ms tick from the mulberry32 seed 0x9e77a1, so every run replays in the same order.

03A speed label computed from elapsed-byte deltas

Built for large file uploads and admin upload logs: every progress tick divides the byte delta since the last tick by the time delta (performance.now) to recompute KB/s, keeps the last five samples as a rolling average, and eases the shown value toward it.

A speed label computed from elapsed-byte deltas
Open in a new tab

03 · A speed label computed from elapsed-byte deltas — Measured area 3.254% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

performance.nowProgressEventrequestAnimationFrame
function tick(target) {
  shownKbps += (target - shownKbps) * 0.3;
  speed.style.setProperty('--n', Math.round(shownKbps));
  if (Math.abs(target - shownKbps) > 0.5) {
    raf = requestAnimationFrame(function () { tick(target); });
  } else {
    raf = null;
  }
}

function pump() {
  if (root.classList.contains('is-demo')) return;
  var chunk = 60000 + rand() * 140000;
  loaded = Math.min(TOTAL, loaded + chunk);
  bus.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: loaded, total: TOTAL }));
  if (loaded >= TOTAL) clearInterval(timer);
}

Real division, then smoothing

The progress listener keeps the last five samples of bytes-over-seconds, averages them, writes the bar fill, and hands the average to tick as its target; the 0.3 lerp only stops the readout twitching on one fast chunk.

04Multi-file stagger — progress rows that queue in one by one

For multi-attach forms and image gallery uploaders. Each row multiplies its registration index by a fixed 80 ms delay before appearing, so dropping five files at once still stacks the list one row at a time while each fill climbs toward its own data-target.

Multi-file stagger — progress rows that queue in one by one
Open in a new tab

04 · Multi-file stagger — progress rows that queue in one by one — Measured area 11.172% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

Array.prototype.mapanimation-delayrequestAnimationFrame
var rows = Array.prototype.map.call(list.querySelectorAll('.ms__row'), function (row, i) {
  row.style.animationDelay = (i * STAGGER_MS) + 'ms';
  return { row: row, fill: row.querySelector('.ms__fill'), value: 0 };
});

function step() {
  if (root.classList.contains('is-demo')) return;
  var done = true;
  rows.forEach(function (r) {
    var target = Number(r.fill.getAttribute('data-target'));
    if (r.value < target) {
      r.value = Math.min(target, r.value + 2 + rand() * 6);
      r.fill.style.transform = 'scaleX(' + (r.value / 100) + ')';
      done = false;
    }
  });
  if (!done) requestAnimationFrame(step);
}

One delay constant, four separate fills

The fills run concurrently in one frame loop, each row advancing 2–8 seeded steps toward its own target of 100, 72, 45, or 18 — remove the stagger and the list loses its one-row-at-a-time stacking.

05Thumbnail pop-in the instant the file finishes reading

For image attachments and product listing forms, FileReader.onload — the moment the dataURL read finishes — sets the thumbnail's background and triggers the overshoot pop, and only then does the bar beneath begin a 900 ms fill timed by performance.now.

Thumbnail pop-in the instant the file finishes reading
Open in a new tab

05 · Thumbnail pop-in the instant the file finishes reading — Measured area 4.903% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

FileReadercreateObjectURLtransform
function fillTo(pct, dur) {
  var t0 = performance.now();
  function step(now) {
    var k = Math.min(1, (now - t0) / dur);
    fill.style.transform = 'scaleX(' + (pct * k / 100) + ')';
    if (k < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

input.addEventListener('change', function (e) {
  var file = e.target.files && e.target.files[0];
  if (!file) return;
  root.classList.remove('is-demo');
  var reader = new FileReader();
  reader.onload = function () {
    thumb.style.backgroundImage = 'url(' + reader.result + ')';
    thumb.classList.add('is-pop');
    fillTo(100, 900);
  };
  reader.readAsDataURL(file);
  // a faster preview reference is also available via createObjectURL (released immediately here)
  var objUrl = URL.createObjectURL(file);
  URL.revokeObjectURL(objUrl);
});

The read finishing is the signal

The pop is keyed to the browser's report of a finished read, not to a timer guessing when a preview might be ready, and inside that callback the source sets the thumbnail and calls fillTo(100, 900) so the bar fills on a clock the frame loop owns; an even faster createObjectURL reference is demonstrated alongside and revoked immediately.

06Success morph — the ring redraws itself into a check

For form submit confirmation and save-button feedback, the frame that reaches 100 percent adds an is-done class that transitions the checkmark path's stroke-dashoffset from its full length down to 0, so the circle reads as continuing into the check rather than disappearing.

Success morph — the ring redraws itself into a check
Open in a new tab

06 · Success morph — the ring redraws itself into a check — Measured area 1.049% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dasharraypathtransition
function paint() {
  shown += (target - shown) * 0.2;
  if (Math.abs(target - shown) < 0.1) shown = target;
  ring.style.setProperty('--p', shown.toFixed(2));
  if (shown >= 100) {
    root.classList.add('is-done');
    raf = null;
    return;
  }
  raf = requestAnimationFrame(paint);
}
function tick() {
  if (root.classList.contains('is-demo')) return;
  target = Math.min(100, target + 6 + rand() * 10);
  if (target >= 100) clearInterval(timer);
}

Two measured lengths, one transition

The checkmark path measures its own length with getTotalLength() and the ring its radius, both stored as custom properties, and the class toggle hands the rest to a CSS transition — also the smallest mover of the set at 1.049 percent painted area.

07Error shake and retry that remembers where it broke

For uploads on flaky networks and admin retry UI. The first tick that reaches FAIL_AT (58) fires a ProgressEvent('error'), adds is-error, and shakes the bar; retry aborts only the previous timer via AbortController and calls run(loaded) starting from that same byte count.

Error shake and retry that remembers where it broke
Open in a new tab

07 · Error shake and retry that remembers where it broke — Measured area 2.978% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

ProgressEventAbortControllerkeyframes
function run(startAt) {
  loaded = startAt;
  controller = new AbortController();
  timer = setInterval(function () {
    if (controller.signal.aborted) return;
    loaded = Math.min(100, loaded + 4 + rand() * 8);
    if (!failedOnce && loaded >= FAIL_AT) {
      failedOnce = true;
      clearInterval(timer);
      bus.dispatchEvent(new ProgressEvent('error', { lengthComputable: true, loaded: loaded, total: 100 }));
      return;
    }
    bus.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: loaded, total: 100 }));
    if (loaded >= 100) clearInterval(timer);
  }, 220);
}

retry.addEventListener('click', function () {
  controller.abort();
  root.classList.remove('is-error');
  run(loaded);
});

The loaded byte survives the failure

When the error event lands, loaded still holds the byte count from the moment the transfer broke, and the retry handler passes that value straight back into run — the bar picks up from 58 instead of making a person wait again for bytes already sent.

08Chunked pause and resume that only re-fills the sent part

For large backup uploads and mobile network handoffs — the file is cut into 256 KB pieces across six chunks with Blob.slice and sent in order via fetch, pausing aborts only the in-flight chunk and stores lastConfirmed, and resume calls sendFrom(lastConfirmed) to recompute the fill starting from that exact index.

Chunked pause and resume that only re-fills the sent part
Open in a new tab

08 · Chunked pause and resume that only re-fills the sent part — Measured area 3.703% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

Blob.slicefetchAbortController
function paint(n) {
  doneEl.textContent = String(n);
  for (var i = 0; i < segEls.length; i++) segEls[i].classList.toggle('is-filled', i < n);
}

async function sendFrom(startIdx) {
  running = true;
  controller = new AbortController();
  for (var i = startIdx; i < TOTAL_CHUNKS; i++) {
    if (paused) { running = false; return; }
    var chunk = blob.slice(i * CHUNK_BYTES, (i + 1) * CHUNK_BYTES);
    try {
      await fetch(location.href, { method: 'HEAD', signal: controller.signal });
    } catch (err) {
      // no real upload server behind this demo — on failure it moves to the next chunk anyway
    }
    if (paused) { running = false; return; }
    lastConfirmed = i + 1;
    paint(lastConfirmed);
    await delay(160 + rand() * 140);
  }
  running = false;
}

The chunk index is the resume point

lastConfirmed is written only after a chunk finishes, so pausing mid-chunk discards that one in-flight chunk and keeps every confirmed one, and resume re-slices forward from the stored index — never from the start, never skipping ahead; the fetch targets this page itself with a HEAD request carrying the abort signal, standing in for the real endpoint, since the demo has no upload server behind it.

09Paste capture — an upload that starts with Ctrl+V

For screenshot attachments and bug-report forms: the paste handler checks clipboardData (a DataTransfer instance), finds an item whose kind is 'file' with a type starting in image/, pulls it with getAsFile(), and pushes it into the same pipeline as the dropzone — no drag anywhere.

Paste capture — an upload that starts with Ctrl+V
Open in a new tab

09 · Paste capture — an upload that starts with Ctrl+V — Measured area 18.947% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

pasteclipboardDataDataTransfer
var objectUrl = null;

function process(file) {
  root.classList.remove('is-idle');
  root.classList.add('is-spin');
  if (objectUrl) URL.revokeObjectURL(objectUrl);
  objectUrl = URL.createObjectURL(file);
  setTimeout(function () {
    img.src = objectUrl;
    root.classList.remove('is-spin');
    root.classList.add('is-done');
  }, 620);
}

A ring with no percentage, on purpose

A paste has no byte stream to measure yet, so this item shows an indeterminate ring for 620 ms and then swaps in the thumbnail — progress here would be a fabricated number, and the set's one law is that no displayed value is fabricated; the createObjectURL/revokeObjectURL pair keeps exactly one reference alive per paste, and the class chain accumulates state instead of redrawing from scratch.

The neighbors on this site take the same computed-first stance: CSS loading animations that keep a spinner honest about what is actually pending, and a button set whose effects stay wired to real pointer state — this post is the upload-side member of that family.

Toggling dragenter/dragleave without a depth counter is where the border breaks

These are the failure modes the set guards against, each one a way the computation quietly leaves the screen. Filling a ring or bar with an arbitrary CSS animation — just counting a width up — is the first: items 02 and 06 compute a circumference from a measured radius, and without that math the label and the arc disagree. Toggling the dropzone highlight on plain dragenter/dragleave is the second: the pair refires at every child boundary, and the border flickers. Restarting a retry from zero is the third — item 07 keeps the loaded byte count at the moment it broke, and a person who already waited for those bytes should never wait for them twice. Skipping the chunk-index store in item 08 is the fourth: without lastConfirmed, resume has no way to know how far it got and ends up re-slicing from the start or skipping chunks. And a transfer speed that simply increments each tick can never be swapped for the real progress event, because its shape was never a division. The full code — nine vanilla scenes plus nine React ports — sits in the password zip behind this post; it opens with q73rkhs7 and every fence above appears there in its complete file. Item 03's speed label is the brightest element of the set at a measured 163.6 average intensity, and the error shake follows at 161.2 — the two readouts people stare at hardest are the two that move hardest.

Why the number and the ring always agree — circumference and time-delta math

The premise running under all nine is one sentence: the target is computed, the display eases toward it. Reference specs for the real APIs behind that premise: Drag and Drop API · FileReader · Blob.slice · stroke-dashoffset · ClipboardEvent. Items 02 and 06 measure a real radius with getBoundingClientRect, derive 2πr, feed that constant into stroke-dasharray, and wind stroke-dashoffset back by the percent — the arc cannot disagree with the label because both are drawings of the same variable. Item 03 does the same for time: on every progress tick, the byte delta since the last tick divided by the performance.now delta is a real KB/s, and only the smoothing (a five-sample rolling average, then a 0.3 lerp) is cosmetic. Skip the math and animate a width or a dash offset directly, and the number and the shape drift apart exactly when a person is watching them closest — on a slow connection. That is also why every seeded jitter comes from a mulberry32 generator built once and consumed in sequence: the same run replays the same way every time, so what you verify is what a reader gets.

Change the ring thickness, the stagger gap, or the chunk size, and accessibility comes along too

Three knobs, each changing one value:

Variant Change What it does to the feel
Ring thickness 02 stroke-width 6 → 14 the ring reads as a donut, and color registers before the percent does
Stagger gap 04 delay 80 ms → 220 ms the stacking rhythm gets clearer, but the wait before the fifth row appears grows
Chunk size 08 256 KB → 1 MB fewer resume points, but a failure loses a larger unsent piece that must be resent

For prefers-reduced-motion: reduce, every item turns off loops and keyframe animation and freezes a representative progress state instead — a ring held at 62 percent, one list row already done — never collapsing back to zero or blank, because a reduced-motion request means turn off the movement, not erase the information. Each widget carries a human-readable aria-label and a tabindex, and the multi-row layouts use role="list" and role="listitem" so the stack is announced as a list. One line under each demo names the real web API it leans on — stroke-dashoffset, getBoundingClientRect, Blob.slice — so the fence you copy is also the API you go read about.

Where this actually gets used — a team building an upload form

A team rebuilding an attachment field usually starts with item 01's dropzone highlight. A single-file slot for a resume or a contract often has an icon and a caption sitting inside the drop target, and the depth counter keeps the border lighting once and unlighting once no matter how a form library nests those children.

An admin screen that accepts a batch of files at once — product photos, several attachments on one ticket — is where item 04's multi-file stagger fits. The queued-in rhythm makes it obvious at a glance how many rows are still pending, and the delay constant alone controls how long the fifth row feels like it is waiting.

Large uploads — video, a backup archive — call for item 08's chunked pause and resume. Because resume starts from lastConfirmed instead of byte zero, a shaky mobile connection that drops mid-transfer never forces the whole file to start over.

Questions that come up before wiring these nine into a real upload form

Can I use these file upload progress bar ui widgets in React?

Yes — the zip carries a React port of each of the nine scenes alongside the vanilla files, built on the same computed-target logic rather than a re-implementation that only looks similar. The fences in this post are the vanilla sources; the ports share the circumference math, the rolling average, the chunk index, and the retry byte count, so behavior matches frame for frame.

Why does the dropzone flicker on some sites and not here?

Because those sites toggle the highlight directly on dragenter and dragleave. Those two events fire again every time the pointer crosses a child node inside the zone — an icon, a label — so the border blinks at every boundary. Item 01 keeps a depth counter that adds one on enter and subtracts one on leave and lights the highlight only above zero, which cancels the duplicate pairs.

How does resume avoid resending chunks that already arrived?

Each confirmed chunk writes lastConfirmed = i + 1, the index just past the chunk that finished. Pausing aborts only the in-flight chunk via AbortController, so confirmed chunks are never touched; resume calls sendFrom(lastConfirmed) and re-slices from that index times the chunk size onward. The one chunk that was mid-flight when the pause landed is the only one resent.

Enter the archive password

The password is inside this article. You will find it as you read.