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

Download Progress Bar: 9 Loading UI Patterns

This download progress bar roundup: One of nine keeps each file's progress value climbing in a list, another resets it to zero and raises a retry count on

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

All nine react to the same trigger family — a real click on a button for eight of them, and a real pointerdown/pointermove/pointerup drag for item 04 alone — and none of them leave a value to chance: every number moves by a weighted sum, a moving average, an elapsed-time accumulator, a linear or spring interpolation, a seeded pass/fail array, or an exponential decay, never by Math.random(). Nothing here calls fetch(), touches File or Blob, or reads navigator.onLine — the "downloading," the "extracting," and the "corrupted" states are local variables recalculated in the browser, so no claim below should be read as "the server confirms the transfer" or "this detects your real connection." If you're studying a download progress bar design for your own project, every one of the nine below computes its own percentage from a plain JavaScript variable, not from a real byte count — which is exactly what makes a download progress bar animation like these safe to copy without wiring up an actual file transfer first.

Anatomy of nine patterns — which value changes and when

Three items (01, 03, 09) turn a single elapsed or accumulated value into a fill's scaleX, but each does something different with it: 01 sums three independent per-file values into a fourth, size-weighted total rather than averaging three percentages evenly; 03 banks whatever the elapsed-time value already reached in pausedElapsed so a pause never rewinds it to zero; and 09 draws that same fill twice, once with a live transform and once as a plain digit swap with nothing animating at all. Two items (02, 08) build a value out of many small values at once — 02's moving average blends the last N raw samples into a second, smoother line running beside the noisy one, and 08 integrates a spring equation frame by frame rather than reading a single elapsed number directly. Item 05 lerps eight point coordinates in lockstep with one progress value, reshaping a polygon rather than tweening an opacity or a scale. Item 06's retry count and item 04's order array both hold small integers that reorder or reset other values around them, but 06's count feeds an exponential backoff wait while 04's order only ever drives a position it eases toward. Item 07 is the only one of the nine computing an exponential decay across a fixed number of repeated passes rather than a value that changes once per cycle. None of this is a single download flag flipping once — every one of the nine keeps recalculating its own value on a repeating schedule for as long as the demo runs, and only one of the nine, item 04, updates that value from a real drag rather than from time alone.

Code per pattern

01Parallel multi-file progress values — each file's progress value updates independently within a list while several downloads run at once

While three files download at once, each row's fill bar scales to that file's own byte count against its own SIZES entry, and the bottom .dmf__row--total row draws a fourth value: total bytes downloaded across all three divided by the sum of SIZES — a size-weighted average, not a plain three-way mean of the three percentages. Pausing freezes every row's timer at once; nothing keeps counting in the background.

Parallel multi-file progress values — each file's progress value updates independently within a list while several downloads run at once
Open in a new tab

01 · Parallel multi-file progress values — each file's progress value updates independently within a list while several downloads run at once — Measured area 3.927% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dmf__row--totalSIZES.dmf__fill
  // Seeded: per-file size (KB, virtual) and a per-tick speed array (KB/tick) to add. No real fetch or File API
  var SIZES = [420, 980, 640];
  var SPEEDS = [
    [32, 40, 48, 52, 46, 38, 30, 44, 56, 60, 50, 36],
    [58, 64, 60, 52, 44, 38, 46, 54, 62, 58, 48, 40],
    [20, 26, 32, 38, 44, 40, 34, 28, 24, 30, 36, 42]
  ];
  var TICK_MS = 140;
  var HOLD_MS = 700;

  var done = [0, 0, 0];
  var tick = 0;
  var paused = false;
  var timer = null;

  // Calculation layer: overall progress value = sum of per-file downloaded bytes / sum of total sizes (a size-weighted average, not a plain 3-way average)
  function overall() {
    var d = done[0] + done[1] + done[2];
    var s = SIZES[0] + SIZES[1] + SIZES[2];
    return d / s;
  }

What the numbers say

Measured across 23 tracked frames, item 01 moves 3.93% of the frame area at a 133.8 intensity score over 16 moving frames — three thin fills and a total bar, not a scene that fills the frame.

02Transfer-speed value graph — the per-second transfer-rate value keeps redrawing as a graph

Every 160ms a new raw sample lands from a fixed SAMPLES array, and a second value — the mean of the last N samples — is computed alongside it, so the SVG redraws two polylines: .dsg__raw for the noisy per-tick number and a smoother second line for the windowed average. The label above only ever shows that averaged value, never the raw one.

Transfer-speed value graph — the per-second transfer-rate value keeps redrawing as a graph
Open in a new tab

02 · Transfer-speed value graph — the per-second transfer-rate value keeps redrawing as a graph — Measured area 8.108% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dsg__graphN.dsg__btn
  // Calculation layer: moving average of the last N raw samples -- the graph line connects these averaged values, not the raw ones
  function movingAvg(arr) {
    var out = [];
    for (var k = 0; k < arr.length; k++) {
      var s = Math.max(0, k - N + 1);
      var sum = 0, c = 0;
      for (var j = s; j <= k; j++) { sum += arr[j]; c++; }
      out.push(sum / c);
    }
    return out;
  }

What the numbers say

Measured across 23 tracked frames, item 02 moves 8.11% of the frame area at a 108.0 intensity score over 16 moving frames, the redrawing SVG polylines accounting for most of that area.

03Pause/resume state-value switch — the progress value stops changing and starts again each time the state value toggles between paused and running

A single elapsed-time value keeps counting up in requestAnimationFrame, but pausing doesn't zero it — pausedElapsed banks whatever had already accumulated so resuming continues from that exact point rather than restarting. The demo also auto-pauses itself twice, at 28% and 62% of TOTAL_MS, before finishing and looping back to zero.

Pause/resume state-value switch — the progress value stops changing and starts again each time the state value toggles between paused and running
Open in a new tab

03 · Pause/resume state-value switch — the progress value stops changing and starts again each time the state value toggles between paused and running — Measured area 1.799% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dpr__stateAUTO_PAUSE_AT.dpr__fill
  // Calculation layer: elapsed = value accumulated before pausing (pausedElapsed) + (now - resume time) -- doesn't reset to zero on resume
  function elapsed() {
    if (paused) return pausedElapsed;
    return pausedElapsed + (performance.now() - startedAt);
  }

  function paint() {
    var e = Math.min(TOTAL_MS, elapsed());
    var p = e / TOTAL_MS;
    fill.style.transform = 'scaleX(' + p.toFixed(4) + ')';
    pctEl.textContent = Math.round(p * 100) + '%';
    state.textContent = paused ? 'Stopped' : (p >= 1 ? 'Done' : 'In progress');
    root.classList.toggle('dpr--paused', paused && p < 1);
    return p;
  }

What the numbers say

Measured across 23 tracked frames, item 03 moves only 1.80% of the frame area at a 151.8 intensity score over 18 moving frames — a single bar and a short state label, the smallest shape of the nine.

04Queue order-value reordering — the order value of not-yet-started items changes to match a sequence the user sets

Four .dqr__item rows hold a shared order array, and every frame each one eases toward its assigned row height by closing LERP (0.18) of the remaining distance rather than jumping there. A scripted autoSeq swaps two waiting items every 900ms on its own, and a real pointerdown/pointermove/pointerup sequence lets a visitor drag a row into a new slot instead.

Queue order-value reordering — the order value of not-yet-started items changes to match a sequence the user sets
Open in a new tab

04 · Queue order-value reordering — the order value of not-yet-started items changes to match a sequence the user sets — Measured area 20.906% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dqr__itemLERPROW_H
  // Calculation layer: when the order value changes, it doesn't jump straight to the target position -- each frame it closes the remaining distance by the LERP ratio
  function tickLerp() {
    for (var i = 0; i < items.length; i++) {
      if (i === dragging) continue;
      var t = targetY(i);
      y[i] += (t - y[i]) * LERP;
      items[i].style.transform = 'translateY(' + y[i].toFixed(2) + 'px)';
    }
    requestAnimationFrame(tickLerp);
  }
  requestAnimationFrame(tickLerp);

What the numbers say

Measured across 23 tracked frames, item 04 moves 20.91% of the frame area at a 144.1 intensity score over 14 moving frames, the highest of the nine — four rows re-stacking at once covers far more of the frame than any single bar.

05Extraction progress and form values changing together — while the extraction progress value fills, a form value changes along with it

As the extraction percentage climbs from 0 to 1 inside a requestAnimationFrame loop, that same progress value drives lerpPoints(), which linearly interpolates all eight BOX corner coordinates toward their matching OPEN coordinates — the polygon reshapes point by point rather than one shape fading into another stacked on top of it.

Extraction progress and form values changing together — while the extraction progress value fills, a form value changes along with it
Open in a new tab

05 · Extraction progress and form values changing together — while the extraction progress value fills, a form value changes along with it — Measured area 3.347% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dem__polyDUR_MS.dem__shape
  // Calculation layer: linearly interpolate (lerp) each of the 8 point coordinates by the progress value (0-1) -- it doesn't crossfade two overlapping shapes
  function lerpPoints(t) {
    var out = [];
    for (var i = 0; i < BOX.length; i++) {
      var x = BOX[i][0] + (OPEN[i][0] - BOX[i][0]) * t;
      var y = BOX[i][1] + (OPEN[i][1] - BOX[i][1]) * t;
      out.push(x.toFixed(1) + ',' + y.toFixed(1));
    }
    return out.join(' ');
  }

  function paint(t) {
    poly.setAttribute('points', lerpPoints(t));
    fill.style.transform = 'scaleX(' + t.toFixed(3) + ')';
    pctEl.textContent = Math.round(t * 100) + '%';
  }

What the numbers say

Measured across 23 tracked frames, item 05 moves 3.35% of the frame area at a 105.4 intensity score over all 23 of 23 frames — the only one of the nine that changes on every single tracked frame.

06Corruption-retry count value — the retry-count value climbs by one and the progress value resets to the start whenever verification fails

A five-entry VERIFY_SEED array decides whether each pass "succeeds": the first two always fail, so the fill resets to zero and retry climbs by one every time, and each wait before retrying is BASE_BACKOFF_MS times BACKOFF_MULT raised to the retry count — an exponential backoff, not a fixed delay.

Corruption-retry count value — the retry-count value climbs by one and the progress value resets to the start whenever verification fails
Open in a new tab

06 · Corruption-retry count value — the retry-count value climbs by one and the progress value resets to the start whenever verification fails — Measured area 2.188% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dcr__retryBACKOFF_MULTVERIFY_SEED
  // Calculation layer: retry wait = base value x multiplier^retry-count (exponential backoff) -- the wait grows longer after every failure
  function backoffMs() {
    return BASE_BACKOFF_MS * Math.pow(BACKOFF_MULT, Math.min(retry, 4));
  }

  function paint() {
    fill.style.transform = 'scaleX(' + (pct / MAX_PCT).toFixed(3) + ')';
    retryEl.textContent = 'Retry ' + retry;
  }

What the numbers say

Measured across 23 tracked frames, item 06 moves 2.19% of the frame area at a 158.7 intensity score over 16 moving frames, the sharpest intensity of the nine — the fill resetting to zero and the state label swapping color read as abrupt, high-contrast changes even though the shape itself stays small.

07Completion-value flash — the completion value flips to one the instant a download finishes, then falls back to zero after a set time

The instant a cycle re-enters its visible window, a skewed .dcf__sweep streak plays across the badge up to SWEEP_COUNT times, and each pass's opacity is DECAY raised to that pass's index — so the streak is bright on its first sweep and nearly gone by the third, an exponential fade rather than a linear one.

Completion-value flash — the completion value flips to one the instant a download finishes, then falls back to zero after a set time
Open in a new tab

07 · Completion-value flash — the completion value flips to one the instant a download finishes, then falls back to zero after a set time — Measured area 5.561% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dcf__sweepDECAYSWEEP_COUNT
  // Calculation layer: sweep intensity = initial value x DECAY^pass-index (exponential decay) -- it fades a little more each pass until it's gone
  function sweepOpacity(passIndex) {
    return Math.pow(DECAY, passIndex);
  }

  function frame(now) {
    var e = (now - start) % CYCLE_MS;
    var on = e < VISIBLE_MS;
    root.classList.toggle('dcf--on', on);
    if (on) {
      var passLen = SWEEP_MS;
      var passIdx = Math.floor(e / passLen);
      var passT = (e % passLen) / passLen;
      if (passIdx < SWEEP_COUNT) {
        var x = -30 + passT * 160; // %
        sweep.style.transform = 'translateX(' + x.toFixed(1) + '%) skewX(-18deg)';
        sweep.style.opacity = sweepOpacity(passIdx).toFixed(3);
      } else {
        sweep.style.opacity = '0';
      }
    }
    raf = requestAnimationFrame(frame);
  }

What the numbers say

Measured across 23 tracked frames, item 07 moves 5.56% of the frame area at only a 57.4 intensity score over 17 moving frames, the softest intensity of the nine — the sweep is a translucent streak, not a hard-edged swap.

08Finished-item position-value move — a finished item's position value shifts into a set area of the list

Two .dfr__item cards leave their own list on a staggered delay and travel toward the shared .dfr__zoneSlot, but the position along that path comes from integrating a mass-spring equation (ZETA 0.42, W 7.5) every frame — a spring(u) value that overshoots the slot by design before damping back to rest, not an eased CSS transition.

Finished-item position-value move — a finished item's position value shifts into a set area of the list
Open in a new tab

08 · Finished-item position-value move — a finished item's position value shifts into a set area of the list — Measured area 12.302% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

.dfr__zoneSlotZETAW
  // Constants: spring damping ratio (zeta), angular frequency (w), per-item start delay (ms), move duration (ms)
  var ZETA = 0.42;
  var W = 7.5;
  var START_DELAY = [0, 1100];
  var MOVE_MS = 850;
  var CYCLE_MS = 2900;

  // Calculation layer: the position progress is computed every frame with a mass-spring equation (it overshoots the target and settles back) -- not a CSS transition
  function spring(u) {
    var wd = W * Math.sqrt(1 - ZETA * ZETA);
    var e = Math.exp(-ZETA * W * u);
    return 1 - e * (Math.cos(wd * u) + (ZETA * W / wd) * Math.sin(wd * u));
  }

What the numbers say

Measured across 23 tracked frames, item 08 moves 12.30% of the frame area at a 137.8 intensity score over only 10 of 23 moving frames, the fewest moving frames of the nine — the spring settles and holds still well before the cycle repeats, which is exactly what makes the overshoot itself worth watching closely rather than any coverage of the frame.

09Reduced-motion static download notice — with reduced motion, only the progress value's label changes instead of animating

The same percentage value paints two panels differently on purpose: the left one updates a scaleX transform on every tick, while the right .drm__staticBox panel only swaps the digits inside its text node, with no transform and no layout change at all. A button toggles a .drm--force-reduce class that turns off the left panel's own transition, standing in for the OS-level reduced-motion setting.

Reduced-motion static download notice — with reduced motion, only the progress value's label changes instead of animating
Open in a new tab

09 · Reduced-motion static download notice — with reduced motion, only the progress value's label changes instead of animating — Measured area 0.643% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

.drm__staticBoxSTEP.drm--force-reduce
  // Constants: progress speed (%/tick), tick interval (ms)
  var STEP = 4;
  var TICK_MS = 130;
  var forcedReduce = false;

  var pct = 0;

  // Calculation layer: one shared progress value is drawn two different ways -- the left panel updates a transform every tick, the right panel only swaps its label text (zero layout, zero transition)
  function paint() {
    fillA.style.transform = 'scaleX(' + (pct / 100).toFixed(3) + ')';
    pctA.textContent = pct + '%';
    pctB.textContent = pct + '%';
  }

  function tick() {
    pct = (pct + STEP) % 101;
    paint();
    setTimeout(tick, TICK_MS);
  }

What the numbers say

Measured across 23 tracked frames, item 09 moves only 0.64% of the frame area at a 131.3 intensity score over 18 moving frames — the smallest area of the nine, which fits a component built to be compared panel by panel rather than by how much of the frame it covers.

Where values reshape a pattern — refresh interval, retry criteria, color-stage thresholds

Moving-average window widened. Item 02's N moves from 5 to 10: widening the window makes the averaged line follow recent spikes far less sharply, trading responsiveness for a smoother curve that lags a beat behind any sudden change in the raw samples.

Exponential backoff multiplier lowered. Item 06's BACKOFF_MULT moves from 2 to 1.6: a multiplier of 2 makes the wait between retries balloon fast enough that the pause between attempts becomes obvious, while 1.6 keeps the wait growing more gently, so retries land closer together.

Spring damping ratio raised. Item 08's ZETA moves from 0.42 to 0.65: at 0.42 the card visibly overshoots the .dfr__zoneSlot and springs back before settling, while 0.65 damps that overshoot down until the card arrives calmer and closer to its final position on the first pass.

None of these nine patterns confirm a single byte with a real server. File Upload Progress Bar Loader: 9 UI Demos (329) works the axis of one upload's own progress loader climbing alone, not several downloads sharing one weighted total like item 01 here. Infinite Scroll Loading: 9 Essential UI Patterns (349) works the axis of the next batch loading in at the bottom of a list, a different trigger from any of these nine reaching 100%. Pull to Refresh CSS: 9 Loading Patterns (353) works the axis of a pulled distance driving a refresh, the closest sibling in spirit to item 03's own elapsed-value accumulator here. Each of these nine calculations, source included, unlocks the moment you enter cb2aueyy on the zip below.

Where it breaks — the instant offline returns, concurrent multi-tab downloads, reduced motion

The instant offline returns

None of the nine items call navigator.onLine or listen for a real online event, and none of them model a network connection dropping in the first place — item 06's VERIFY_SEED array decides pass or fail by a fixed sequence, not by whether a connection returns, so a corrupted-file retry here is a stand-in for a failed integrity check, not a reconnect. A demo like this can show what happens after a retry succeeds or fails, but it can't show the moment a real network comes back, because there's no real network to come back.

Concurrent multi-tab downloads

Opening the same item in a second tab starts a fully independent copy — none of the nine reads or writes localStorage, a BroadcastChannel, or any other channel a second tab could see, so two tabs can run item 01's three files or item 06's retry loop at once without either tab knowing the other exists. A real multi-file download manager sharing one queue across tabs would need exactly that shared channel, which is the piece deliberately left out here.

Reduced motion

All nine read their reduced-motion behavior from a single @media (prefers-reduced-motion: reduce) block in each item's own SCSS, and none of the nine branch on window.matchMedia() in script at all, so the calculation itself never gates on the OS setting — only a transition value changes, and that change isn't always visible on the value itself. Four items — 01, 06, 07, and 09 — remove a transition that sits directly on the value element: .dmf__fill, .dcr__fill, and .drm__fill each carry their own transition: transform $dur-instant linear, and .dcf__badge carries transition: opacity $dur-quick ease-out, transform $dur-quick ease-pop, so switching the rule on makes that element's own display snap between updates instead of easing into them. Three items — 03, 05, and 08 — target only their button (.dpr__btn, .dem__btn, .dfr__btn); their fill, polygon, and spring position are all driven purely by JavaScript with no transition of their own to remove, so reduced motion there quiets a hover press on the button and leaves the value's own motion untouched. Item 02's rule lists .dsg__avg and .dsg__raw next to its button, but neither line ever declares a transition in the first place, so only .dsg__btn's own transition actually changes. Item 04's rule targets .dqr__item, which likewise never declares a transition of its own — and 04 has no button either — so that block changes nothing at all. In every one of the nine, though, the JavaScript driving each value, whether that's a requestAnimationFrame loop or a setTimeout chain, keeps recalculating every value exactly as before; reduced motion never gates the calculation itself. Item 09 is the only one built specifically to show what an actual transition removal leaves behind: turn its own .drm--force-reduce class on and the left panel's fill stops transitioning between the transform updates it still receives every tick, while the right panel — which never had a transition to begin with — looks identical to how it always did.

Three variants and accessibility

Variant What changes What it doesn't touch
Moving-average window widened N 5 → 10 (item 02) The per-tick SAMPLES array itself
Exponential backoff multiplier lowered BACKOFF_MULT 2 → 1.6 (item 06) The VERIFY_SEED pass/fail sequence
Spring damping ratio raised ZETA 0.42 → 0.65 (item 08) Item 08's own W of 7.5

None of the nine adds an aria-* attribute, a role, or a tabindex anywhere in its markup — a screen reader only reaches a status change here if whoever adapts this code adds a live region themselves. What is native: items 01 through 09 all trigger from a real <button type="button">, except item 04, whose rows respond to pointerdown, pointermove, and pointerup directly rather than through any button element; none of the nine sets a native disabled attribute at any point. No item swaps in a bundled icon asset for a state change either — every visual change here is a class, a transform, a scaleX, or a text node being set directly.

FAQ

Why does item 04 need pointerdown, pointermove, and pointerup instead of a click, like the other eight?

Reordering a queue is a continuous gesture, not a single moment — item 04 has to track where a row is while it's still moving so it can update order mid-drag, which a single click event can't express; the other eight only ever flip a state once per press, so a click listener is enough.

Do any of these nine percentages come from a real file size or network speed?

No — every number here, from item 01's SIZES array to item 02's SAMPLES list, is a fixed value read out of the script; nothing calls fetch(), reads a real Content-Length header, or measures actual bandwidth, so treat each demo as arithmetic to study, not a live transfer to trust.

Is this the same as a full download manager ui design, or just nine isolated patterns?

Just the patterns — each of the nine focuses on one calculation (a weighted total, a moving average, a spring, an exponential backoff) in isolation, and none of them assembles a full download manager ui design with a shared queue, a settings panel, or file-type icons; wiring nine of these into one interface is a separate design decision the zip leaves entirely up to you.

Sources: MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — transition

Enter the archive password

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