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

Buffering Animation CSS — 9 Loading Patterns

Among nine buffering animation css ideas, one draws two stacked rings for playback and buffer, one fills a waveform only as buffered, and one flags a badge

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

Nine buffering indicator UI patterns sit side by side below, each one a plain HTML/CSS/JS file with no library and no build step, so you can drag any single folder out of the archive and open its index.html straight in a browser.

Demo

Click each card above to stall it, drag its scrub bar, or force a quality change, and watch how it recovers — every reaction described below comes from reading that demo's actual source, not a description written from memory.

Anatomy of nine patterns — where the one layer of calculation runs

The numbers in the table come from the same rendered captures the demos above produced, not an estimate — 24 frames per pattern, sampled the same way for every row. Cumulative area totals how much of each frame the moving pixels covered; average intensity is the strength of that pixel-level change on the frames where something actually moved; frames moved (of 23) is how many of the 23 frame-to-frame comparisons showed any difference at all.

# Pattern Cumulative area Avg. intensity Frames moved (of 23)
01 Buffering Overlay on a Frozen Frame 4.02% 71.5 20
02 Dual Ring for Playback and Buffer 1.78% 140.2 23
03 Signal-Bar Buffering Meter 2.56% 141.2 13
04 Waveform That Fills as It Buffers 4.61% 168.3 23
05 Retry Icon That Shakes After Stall Detection 10.05% 47.2 17
06 ETA Recalculated From Remaining Size and Speed 1.79% 106.0 23
07 Scrub Bar's Ghost Buffer Preview 3.03% 143.0 23
08 Quality Badge Driven by Bandwidth Judgment 1.53% 67.9 16
09 Reduced Motion On vs Off, Side by Side 0.96% 110.3 23

Pattern 05 covers the most cumulative area of the nine at 10.05%, since a shaking icon sweeps back and forth across a wide arc, while pattern 09 covers the least at just 0.96%, since two small rotating rings sitting side by side barely disturb the frame either way. Pattern 04 posts the highest average intensity of the set at 168.3 — the only one to clear 150 — because a mask peeling back across a waveform changes more pixel value per moved frame than any rotation or color blend here, while pattern 05 itself sits at the opposite end with the lowest average intensity, 47.2, since most of its sampled frames catch a resting icon between shakes rather than mid-shake. Patterns 02, 04, 06, 07, and 09 each move across all 23 of the 23 frame-to-frame comparisons, tied for the most, while pattern 03 moves across just 13, the fewest of the nine, since its own hold logic keeps the bars still for stretches even while the signal underneath keeps changing.

Cluster A — a composite sine wave fakes the signal, then a second layer decides when to trust it (01, 03, 06, 08)

Patterns 01, 03, 06, and 08 all start the same way: a function combining two or three Math.sin() terms stands in for a network condition — a stall's jitter, a connection's speed, a transfer rate, a bandwidth reading — since none of these demos touch a real network. What differs is the second layer applied on top: 01 feeds that value straight into blur and opacity, 03 and 08 both refuse to update their visible state until a new reading has held for a fixed number of ticks or milliseconds, and 06 divides a remaining amount by that faked speed to get a number of seconds.

Cluster B — elapsed time or a fixed fraction runs one formula, no sensor involved (02, 04, 05)

Patterns 02, 04, and 05 never read anything resembling a live signal — 02's ring geometry comes from two independent fractions handed to one conversion function, 04's per-bar bump comes from a fixed easing curve applied to a progress fraction, and 05's icon shake comes from how long a stall has already run past its threshold, fed through a decaying sine wave. Swap the fraction or the elapsed time and the visual output changes; nothing here is listening for anything external.

Cluster C — the two patterns that read something real instead of faking it (07, 09)

Pattern 07 is the only one of the nine whose core number comes from measuring an actual DOM element: getBoundingClientRect() gives the track's real width, and the pointer's live X coordinate divided by that width becomes the ghost marker's position. Pattern 09 is the only one that keeps listening after its first check — a matchMedia 'change' handler updates its status text the instant the OS setting actually flips, instead of reading .matches once like the other eight and moving on.

Code per pattern

Below is one function pulled out of each pattern's real file — the exact layer of math that decides what moves, with nothing rewritten and nothing shortened for the page.

01Buffering Overlay on a Frozen Frame

When a stream stalls, a translucent overlay drops over the frozen frame and a spinner turns on top of it, while the paused frame stays visible underneath.

Buffering Overlay on a Frozen Frame
Open in a new tab

01 · Buffering Overlay on a Frozen Frame — Measured area 4.024% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

backdrop-filter@keyframesaria-live
  function tick(elapsedMs) {
    var phase = elapsedMs % STALL_CYCLE_MS;
    var stallSpan = STALL_CYCLE_MS * STALL_PORTION;
    var stalling = phase < stallSpan;
    if (stalling) {
      var stallFrac = clamp(phase / stallSpan, 0, 1);
      var spinMs = SPIN_MS_STALL_START - (SPIN_MS_STALL_START - SPIN_MS_STALL_END) * stallFrac;
      // Two composite sine waves fake network jitter — Math.random would vary every render and wreck the measurement
      var jitter = 0.5 + 0.28 * Math.sin(elapsedMs / 190) + 0.14 * Math.sin(elapsedMs / 61);
      jitter = clamp(jitter, 0, 1);
      ring.style.setProperty('--spin-duration', spinMs.toFixed(0) + 'ms');
      overlay.style.setProperty('--blur', (4 + jitter * 7).toFixed(2) + 'px');
      overlay.style.opacity = (0.5 + jitter * 0.28).toFixed(2);
      if (status.textContent !== 'Buffering') status.textContent = 'Buffering';
    } else {
      overlay.style.opacity = '0';
      if (status.textContent !== 'Playback resumed') status.textContent = 'Playback resumed';
    }
  }

02Dual Ring for Playback and Buffer

Draws two stacked rings — the outer shows how much has buffered, the inner shows actual playback position (a different demo from 329's single ring+number and 035's color-zone ring — the gap between the two rings itself shows how far ahead the buffer is).

Dual Ring for Playback and Buffer
Open in a new tab

02 · Dual Ring for Playback and Buffer — Measured area 1.778% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

conic-gradientstroke-dashoffset--buffer-pct
  // Arithmetic that turns buffer % and playback % into an angle (gradient stop) and a circumference length (stroke-dashoffset)
  function apply(bufferFrac, playFrac) {
    var bp = Math.round(bufferFrac * 100);
    bufferRing.style.setProperty('--buffer-pct', bp + '%');
    var offset = CIRC * (1 - playFrac);
    playValue.style.strokeDashoffset = offset.toFixed(2);
    bufferPctEl.textContent = bp + '%';
    wrap.setAttribute('aria-valuenow', String(bp));
  }

03Signal-Bar Buffering Meter

Four or five signal bars light up and dim in sequence at a rate tied to the buffering speed, so the number of lit bars shows whether the current download is fast or slow. Every loading progress bar css component like this one ultimately reduces to a single number between 0 and 5 — these bars just disagree with the other eight patterns about how that number should reach the screen.

Signal-Bar Buffering Meter
Open in a new tab

03 · Signal-Bar Buffering Meter — Measured area 2.558% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

--bar-leveltransform: scaleYsetInterval
  function tick(ms) {
    if (pinned !== null) return;
    var raw = rawSpeed(ms) * BAR_COUNT;
    var targetLevel = clamp(Math.round(raw), 0, BAR_COUNT);
    if (targetLevel !== displayLevel && Math.abs(raw - displayLevel) > CHANGE_MARGIN) {
      if (targetLevel === pendingLevel) {
        pendingCount++;
      } else {
        pendingLevel = targetLevel;
        pendingCount = 1;
      }
      if (pendingCount >= HOLD_TICKS) {
        displayLevel = pendingLevel;
        pendingCount = 0;
      }
    } else {
      pendingCount = 0;
      pendingLevel = displayLevel;
    }
    paint(displayLevel);
  }

04Waveform That Fills as It Buffers

Lays a translucent film over a static waveform and peels it back only as far as the buffered range extends (a different demo from 291's live-analysis visualizer — here a single buffer-progress value alone drives how far the film retreats, with no mic or file analysis).

Waveform That Fills as It Buffers
Open in a new tab

04 · Waveform That Fills as It Buffers — Measured area 4.609% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

mask-image--buffer-pctlinear-gradient
  function easeFn() {
    if (WAVEFORM_FILL_EASE === 'linear') return function (x) { return x; };
    var nums = WAVEFORM_FILL_EASE.replace('cubic-bezier(', '').replace(')', '').split(',').map(Number);
    return bezier(nums[0], nums[1], nums[2], nums[3]);
  }
  var ease = easeFn();

  function paint(pct) {
    var pctStr = (pct * 100).toFixed(1) + '%';
    film.style.setProperty('--buffer-pct', pctStr);
    wrap.setAttribute('aria-valuenow', String(Math.round(pct * 100)));
    var raw = pct * BAR_COUNT;
    var idx = Math.min(BAR_COUNT - 1, Math.floor(raw));
    var frac = raw - idx;
    bars.forEach(function (b, i) {
      var bump = i === idx ? Math.sin(frac * Math.PI) * BUMP_PX : 0;
      b.style.setProperty('--bump', bump.toFixed(1) + 'px');
    });
  }

05Retry Icon That Shakes After Stall Detection

Once a stall passes a short threshold, the status text switches from "stalled" to "reconnecting" and the retry icon shakes along a decaying sine wave before settling, while the button snaps it straight back to playing.

Retry Icon That Shakes After Stall Detection
Open in a new tab

05 · Retry Icon That Shakes After Stall Detection — Measured area 10.052% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXsetIntervalis-stalled
  function setStatus(text) {
    if (status.textContent !== text) status.textContent = text;
  }

  // Turns the stall's elapsed time (ms) into the phase of a decaying sine wave to compute the shake width — arithmetic every tick, not a CSS keyframe
  function shakeOffset(stallOverMs) {
    var amp = SHAKE_AMP_PX * Math.exp(-SHAKE_DECAY * stallOverMs);
    return amp * Math.sin(stallOverMs * SHAKE_FREQ);
  }

06ETA Recalculated From Remaining Size and Speed

Every tick, it divides remaining size by a freshly recalculated instantaneous speed to update the ETA text, freezing the readout when paused and switching to "download complete" once done (a different demo from 349's retry-wait countdown — this one isn't a wait after failure, it recalculates remaining seconds every moment from the live fill rate).

ETA Recalculated From Remaining Size and Speed
Open in a new tab

06 · ETA Recalculated From Remaining Size and Speed — Measured area 1.794% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

performance.now--fill-pctsetInterval
  // A deterministic composite sine wave fakes the instantaneous transfer speed — a different speed every tick means the ETA is recalculated every time
  function instSpeed(ms) {
    var w = 1 + SPEED_VARIANCE * Math.sin(ms / 480) + 0.2 * Math.sin(ms / 133);
    return Math.max(0.15, BASE_SPEED_MBPS * w);
  }

  function paint(speed) {
    var pct = Math.min(100, (loadedMb / TOTAL_MB) * 100);
    fill.style.setProperty('--fill-pct', pct.toFixed(1) + '%');
    track.setAttribute('aria-valuenow', String(Math.round(pct)));
    detail.textContent = loadedMb.toFixed(1) + 'MB / ' + TOTAL_MB.toFixed(1) + 'MB · ' + speed.toFixed(1) + 'MB/s';
    if (paused) {
      etaEl.textContent = 'Paused';
    } else if (loadedMb >= TOTAL_MB) {
      etaEl.textContent = 'Download complete';
    } else {
      // Remaining size ÷ current speed = ETA — a freshly divided value every tick, not a stored countdown
      var remaining = TOTAL_MB - loadedMb;
      var etaSec = remaining / speed;
      etaEl.textContent = 'About ' + Math.ceil(etaSec) + ' sec left';
    }
  }

07Scrub Bar's Ghost Buffer Preview

Dragging the scrub track converts the pointer's position — measured live against the track's actual width via getBoundingClientRect — into the ghost marker's position and time label, while the readout keeps showing both playback and buffer time.

Scrub Bar's Ghost Buffer Preview
Open in a new tab

07 · Scrub Bar's Ghost Buffer Preview — Measured area 3.027% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermove--ghost-pctgetBoundingClientRect
  // Converts the actual pointer coordinate into a fraction of the track (measured live via getBoundingClientRect)
  function fracFromPointer(clientX) {
    var rect = track.getBoundingClientRect();
    return clamp((clientX - rect.left) / rect.width, 0, 1);
  }

  var dragging = false;

  track.addEventListener('pointermove', function (ev) {
    human();
    var f = fracFromPointer(ev.clientX);
    setGhost(f);
    if (dragging) {
      manualPlayFrac = f;
      paint(f, f);
    }
  });

08Quality Badge Driven by Bandwidth Judgment

A sine-composite bandwidth value is judged high/mid/low every tick, but the badge label only changes once a new judgment holds for QUALITY_BADGE_HOLD_MS (1200ms) to prevent flicker, while the badge's background color keeps blending live via liveColor() in between.

Quality Badge Driven by Bandwidth Judgment
Open in a new tab

08 · Quality Badge Driven by Bandwidth Judgment — Measured area 1.535% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

data-levelbackgroundColorQUALITY_BADGE_HOLD_MS
  // Doesn't switch the moment bandwidth crosses a threshold — only changes the label and icon once
  // that judgment holds for QUALITY_BADGE_HOLD_MS straight, a judgment calc that stops the badge text from flickering on a momentary spike.
  // (The background color still keeps blending every tick via liveColor() above, so the live signal itself isn't hidden.)
  function tick(ms) {
    var mbps = bandwidth(ms);
    if (forced !== null) { paint(forced, mbps); return; }
    var target = judge(mbps);
    if (target !== displayLevel) {
      if (target === pendingLevel) {
        if (ms - pendingSinceMs >= QUALITY_BADGE_HOLD_MS) {
          displayLevel = target;
        }
      } else {
        pendingLevel = target;
        pendingSinceMs = ms;
      }
    } else {
      pendingLevel = displayLevel;
    }
    paint(displayLevel, mbps);
  }

09Reduced Motion On vs Off, Side by Side

The left panel spins a ring by continuously computing angle via tickMotion(), while the right panel updates only in steps via tickReduced() cut into REDUCED_STEP_MS (900ms) chunks from the same elapsed time — with a real matchMedia listener reflecting the OS's actual reduced-motion setting live as the two panels sit side by side.

Reduced Motion On vs Off, Side by Side
Open in a new tab

09 · Reduced Motion On vs Off, Side by Side — Measured area 0.961% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

prefers-reduced-motionaria-livetextContent
  // The real-use path — when the actual OS setting changes, this listener rewrites the text immediately (no reload needed)
  function updateSystemStatus() {
    sysStatus.textContent = 'System setting: reduced motion ' + (mq.matches ? 'on' : 'off');
  }
  mq.addEventListener('change', updateSystemStatus);
  updateSystemStatus();

  // The left panel (full motion) computes the angle continuously and rotates it, while the right panel (reduced motion) takes the same time input
  // and cuts it into REDUCED_STEP_MS chunks (integer division), changing only in steps — a calc that compares both branches against the same clock.
  function tickMotion(ms) {
    var deg = (ms % SPIN_MS) / SPIN_MS * 360;
    ringMotion.style.transform = 'rotate(' + deg.toFixed(1) + 'deg)';
  }

  function tickReduced(ms) {
    var step = Math.floor(ms / REDUCED_STEP_MS) % 2;
    ringReduced.style.setProperty('--dim', step === 0 ? '1' : '.45');
  }

Where values change the UI — speed, threshold, hold time, order

Most loading indicator css write-ups stop at how fast a spinner turns, but every pattern above hangs off one variable that does the real work — change that one variable and the whole pattern's feel shifts with it.

01 — the jitter phase

The overlay's blur and opacity both read from jitter inside tick() — two composite sine waves added together and clamped to 0..1 — so the overlay's exact look at any millisecond comes from a formula lookup, not a step in a CSS @keyframes timeline.

02 — the two fractions

apply() takes only bufferFrac and playFrac as input and turns them into --buffer-pct and a stroke-dashoffset around the circle — hand it any two numbers between 0 and 1 and both rings redraw instantly, whether they came from this demo's timer or a real player's buffered ranges.

03 — the hold count

tick() only lets displayLevel change once the same targetLevel has led for HOLD_TICKS (3) consecutive ticks in a row, which is exactly what keeps the bar count from flickering every time the synthetic signal wobbles past a boundary.

04 — the fill easing

WAVEFORM_FILL_EASE decides which function easeFn() hands back — the literal string 'linear' or a cubic-bezier() string parsed into four numbers — and that one returned function reshapes every bar's --bump value inside paint().

05 — the stall threshold

Nothing shakes until a stall has run past STALL_THRESHOLD_MS (300ms); once it does, shakeOffset() turns the extra elapsed time into a SHAKE_AMP_PX-scaled sine wave that decays toward zero the longer the stall drags on.

06 — the recalculated remainder

paint() divides TOTAL_MB - loadedMb by whatever instSpeed() returns for the current millisecond, so the countdown text is never a stored number ticking down on its own — it's a fresh division on every single tick.

07 — the measured fraction

fracFromPointer() reads the track's real width from getBoundingClientRect() on every pointermove and divides the cursor's offset by it, so the ghost marker's position is a live measurement of the actual element, unlike the timer-driven value every other pattern here relies on.

08 — the hold duration

tick() won't move displayLevel to a new judgment until that judgment has held for QUALITY_BADGE_HOLD_MS (1200ms) straight, while the badge's background color keeps blending continuously through liveColor() in the very same tick — the label debounces, the color never does.

09 — one clock, two formulas

tickMotion() and tickReduced() both receive the exact same ms value every frame — one turns it into a continuous angle for rotate(), the other floors it by REDUCED_STEP_MS (900ms) into a plain 0/1 step — so the only difference between the two panels is which formula reads the clock, not which clock they read.

Where it breaks — accessibility, reduced-motion, slow networks

Pattern 07's track carries role="slider" and tabindex="0", so a keyboard user can already move playback with the arrow keys — paint() runs on keydown exactly the way it runs on pointermove. But paint() always calls setGhost() with the same fraction it just committed to playFrac, so the ghost marker a keyboard user sees sits exactly on top of the position they already landed on. The entire reason a ghost preview exists — showing where you'd land before you commit — only applies to a pointer that can hover without clicking; a keyboard press commits and previews in the same instant, so this pattern's signature feature has nothing left to show once a mouse isn't involved.

What reduced motion leaves behind

Every pattern's manual controls — its button, its drag, its arrow keys — keep working under reduced motion; only the automatic timer that runs the demo without you touching it gets cancelled.

All nine of these files share one password, nj7m3t5r, so unzipping the archive once puts matching HTML, CSS, and JS on disk for every pattern shown above, ready to open directly in a browser.

  • 01 overlay — the jitter and spin-speed cycling stop; the overlay locks to a fixed 0.6 opacity and 6px blur with a permanent "Buffering" label.
  • 02 dual ring — the buffer/play tween stops; apply() runs once with a fixed 62%/40% split instead of animating toward it.
  • 03 signal bars — the level stops re-sampling the synthetic signal and freezes at level 3 ("Signal good").
  • 04 waveform fill — the fill and per-bar bump stop moving; paint(0.55) renders one static 55%-buffered frame.
  • 05 stall shake — only the icon's shake stops; the card still shows the stalled state and its "Stalled" text.
  • 06 countdown ETA — the fill bar stops animating and the countdown text locks to one value computed at 40% loaded.
  • 07 scrub ghost — the demo's own auto-play stops and both markers freeze at one fixed pair of positions (dragging and arrow keys keep working, since those never depended on the timer).
  • 08 quality badge — the judgment loop stops; the badge locks to "Medium quality" at one fixed bandwidth reading.
  • 09 reduced-motion comparison — nothing here is disabled, since the whole point of this pattern is showing the reduced state; updateSystemStatus() keeps reflecting the OS's real setting live either way.

Three variants and accessibility

Variant Value Feel
Thicken the buffer ring stroke 02 BUFFER_RING_STROKE_WIDTH 6px → 14px The gap between the two rings reads as a much thicker band, so how far the buffer leads playback becomes obvious at a glance without any number — a subtle gap turns into an unmistakable margin.
Soften the waveform fill easing 04 WAVEFORM_FILL_EASE linear → cubic-bezier(.22,1,.36,1) The film's retreat eases out toward the end, so the moment the buffer catches up reads as a soft settle rather than an abrupt stop — arrival feels more natural than at a constant rate.
Extend the quality-drop badge hold time 08 QUALITY_BADGE_HOLD_MS 1200 → 3000 The badge lingers more than twice as long, so even if the buffer recovers quickly, there's enough time to actually read that quality just dropped — at the shorter duration it flashed past unread.

Patterns 01, 03, 06, 08, and 09 are each a native <button type="button"> carrying aria-pressed, so Enter and Space already toggle them with no extra keydown code. Pattern 07's track instead carries role="slider" with its own tabindex="0" and arrow-key handling, since a scrub position needs range semantics a plain button can't express. Patterns 02, 04, and 06 wrap their visuals in role="progressbar" with a live aria-valuenow, and pattern 03's meter carries role="img" with a label that updates on every tick — three read-only displays that need no keyboard handling of their own.

FAQ

Do these nine buffering animation css patterns need a real network connection or a video element to run?

No — none of the nine call fetch, read a media element's buffered ranges, or touch new Date(). Every buffer, bandwidth, and network signal above is a composite sine wave computed from performance.now(), so you can test every pattern completely offline and get the same numbers on every load.

How do I check whether prefers-reduced-motion is actually respected in this design?

Turn on "reduce motion" in your OS accessibility settings and reload any demo above — patterns 01 through 08 check window.matchMedia('(prefers-reduced-motion: reduce)').matches once on load and switch to a fixed state, while pattern 09 goes further and keeps listening for that setting to change without a reload, which you can see directly in its updateSystemStatus() function.

Is there a loading progress bar codepen version of these nine patterns, or do I need to download something?

A loading progress bar codepen is handy for a quick look, but there's no embed to load here — downloading the archive gives you the same nine index.html files already unzipped and ready to edit on your own disk.

Sources: MDN — conic-gradient(), MDN — mask-image, MDN — Performance.now(), MDN — prefers-reduced-motion, MDN — aria-live.

Related reading: File Upload Progress Bar Loader: 9 UI Demos · Download Progress Bar: 9 Loading UI Patterns · Audio Visualizer JavaScript: 9 Parts, No Library

Enter the archive password

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