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

Chart Loading Animation — 9 Computed Patterns

Among nine chart loading animation patterns, a bar's delay comes from its column, a heatmap cell's from diagonal distance, and reduced motion skips

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

This set lines up nine chart loading skeletons in one row: a bar chart whose height stagger comes from a column index, a line chart whose flat placeholder unspools along a measured path length, a donut whose waiting ring splits into real segments by angle, a sparkline list that reveals row by row in a wave, a metrics grid that fades in by distance from its corner, a multi-series chart that settles with a damped cascade, a heatmap that fills along a diagonal wave, a fail/empty/retry cycle with an exponential backoff, and a reduced-motion path that skips every one of those calculations. Rather than reach for a separate chart skeleton loader library for each chart type, every pattern below keeps the same click/keydown baseline and adds exactly one small, named calculation function on top of it.

Demo

Bar Chart Loading Skeleton Whose Delay Is Computed Per Bar
Open in a new tab

01 · Bar Chart Loading Skeleton Whose Delay Is Computed Per Bar — Measured area 16.224% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

--phase--realtransform-origin

Pattern 01 above needs nothing beyond barDelay, paintNow, and skeletonNow to turn a column index into a bar's entrance timing. The other eight follow below, each paired with its own code block.

Anatomy of nine chart loading skeletons — where the one layer of calculation runs

# Pattern Cumulative area Avg. intensity Frames moved
01 Bar height stagger 16.22% 85.2 16 of 23
02 Line path reveal 1.89% 101.8 13 of 23
03 Donut ring morph 6.74% 48.2 18 of 23
04 Sparkline row wave 2.56% 83.8 16 of 23
05 Grid fade by distance 28.61% 29.1 9 of 23
06 Multi-series cascade 3.49% 117.0 15 of 23
07 Heatmap diagonal wave 21.91% 49.1 14 of 23
08 Fail/empty/retry 7.96% 35.2 12 of 23
09 Reduced-motion swap 9.88% 89.6 15 of 23

None of the nine call requestAnimationFrame — every delay is set once as a --phase/--delay custom property or a plain animationDelay, and a CSS @keyframes rule or transition does the actual repainting after that; setTimeout only decides when a delay has elapsed or when a state should auto-advance. Four of the nine (02, 04, 06, 07) also call getAnimations().cancel() on the flat placeholder the moment real values are ready, cutting its dash animation short instead of waiting for the loop to finish naturally.

Pattern 05 covers the most pixels of the nine (28.61% cumulative) because all twelve grid cells cross-fade together, but that whole burst finishes inside a maxOrder(5) × STEP_MS(55) = 275ms window, so it also has the lowest average intensity (29.1) and moves in the fewest frames (9 of 23) — wide but brief and gentle. Pattern 02 covers the least (1.89%) since only a single thin line repaints at a time. Pattern 06 has the highest average intensity of the nine (117.0), since settling each series into its real stroke length and dashoffset repaints a whole path at once rather than a small dot or cell. Pattern 03 moves in the most frames (18 of 23) — its dashed ring keeps spinning for the full wait before the real segments cut in, so nearly every captured frame shows some movement somewhere in the ring.

Delay computed straight from an index

Patterns 01, 04, and 05 each turn a position into a delay with no length or geometry step in between. 01's barDelay(i) multiplies the bar's column index by STAGGER_MS. 04's rowDelay(i) does the same with a row index and ROW_WAVE_MS. 05's cellOrder(row, col) adds a grid cell's row and column together to rank it by its distance from the origin cell, then multiplies that rank by STEP_MS.

Length or angle measured before the reveal is timed

Patterns 02, 03, and 06 measure a shape's own geometry before deciding how the reveal plays out. 02's drawPercent(len) reads the line's getTotalLength() and clamps the result to size a stroke-dashoffset keyframe. 03's layout() turns each segment's share of the total into an arc length along the ring's circumference, using the cumulative share so far as that segment's starting angle. 06 also reads each series path's getTotalLength() to size its dash array, and on top of that seriesDelay(i) runs a damped, decaying sum across the series index so later series get a shrinking extra gap.

Distance, backoff, and the reduced-motion escape hatch

Patterns 07, 08, and 09 each add a different kind of guard on top of a delay. 07's waveDelayMs(row, col) measures the real Euclidean distance from a cell to the grid's top-right corner and divides it by WAVE_SPEED. 08's backoffDelay(n) raises BACKOFF_FACTOR to the power of the retry count so a repeatedly failing chart waits longer each time. 09's barDelay(i, reduced) reuses 01's exact formula but multiplies it by a reduced-motion flag, so every bar's delay collapses to zero at once when the setting is on.

Code per pattern

Every code block below comes straight from that pattern's own index.html file — same variable names, same order, nothing shortened or left out.

01Bar Chart Loading Skeleton Whose Delay Is Computed Per Bar

Gray placeholder bars sit in place first, and once values arrive each bar's entrance delay is computed from its column index, so the bars settle into their real height in that left-to-right order. A spot where a dashboard's bar chart holds its place before the stats draw in.

Bar Chart Loading Skeleton Whose Delay Is Computed Per Bar
Open in a new tab

01 · Bar Chart Loading Skeleton Whose Delay Is Computed Per Bar — Measured area 16.224% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

--phase--realtransform-origin
  // Calculation layer: turns the bar's entrance delay into a value from its column index (i) — the further right the column, the later it settles into its real height
  function barDelay(i) { return i * STAGGER_MS; }

  bars.forEach(function (bar, i) {
    bar.style.setProperty('--real', String(VALUES[i] / 100));
    bar.style.setProperty('--phase', barDelay(i) + 'ms');
  });

  function paintNow(i, value) {
    bars[i].style.transform = 'scaleY(' + (value / 100) + ')';
    bars[i].style.opacity = '1';
  }
  function skeletonNow(i) {
    bars[i].style.transform = 'scaleY(' + SKELETON_SCALE + ')';
    bars[i].style.opacity = '.32';
  }

02Line Chart Loading Whose Flat Placeholder Unspools Into the Real Path

A flat dashed line runs first, and once real values arrive the path's total length is measured to work out how long the draw should take before it unspools into the jagged real line. A place where a monthly-trend line chart waits before its data lands.

Line Chart Loading Whose Flat Placeholder Unspools Into the Real Path
Open in a new tab

02 · Line Chart Loading Whose Flat Placeholder Unspools Into the Real Path — Measured area 1.89% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

getTotalLengthstroke-dasharraygetAnimations
  // Calculation layer: uses the line's total length (getTotalLength) to set how much of it needs to draw — the longer it is, the longer the draw takes
  function drawPercent(len) { return Math.max(20, Math.min(48, Math.round(len / PX_PER_PCT))); }

  function prepare() {
    var len = Math.round(real.getTotalLength());
    var pct = drawPercent(len);
    real.style.strokeDasharray = String(len);
    real.style.strokeDashoffset = String(len);
    styleTag.textContent =
      '@keyframes clDraw {' +
      '0%{stroke-dashoffset:' + len + ';}' +
      '14%{stroke-dashoffset:' + len + ';}' +
      pct + '%{stroke-dashoffset:0;}' +
      '86%{stroke-dashoffset:0;}' +
      '100%{stroke-dashoffset:' + len + ';}}';
    real.style.animation = 'clDraw 2000ms ease-in-out infinite';
    return { len: len, pct: pct };
  }

03Donut Chart Loading Whose Waiting Ring Morphs Into Real Segments

A dashed ring spins while waiting, and once values arrive each segment's share is computed as an angle along the ring's circumference, splitting the ring's color into those segment colors in that order. A spot where a completion-rate donut chart spins briefly before its tally is ready.

Donut Chart Loading Whose Waiting Ring Morphs Into Real Segments
Open in a new tab

03 · Donut Chart Loading Whose Waiting Ring Morphs Into Real Segments — Measured area 6.744% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

--delaystroke-dasharraytransform-origin
  // Calculation layer: turns each segment's share into an arc length along the ring's circumference, and its starting angle (cumulative share) sets the order
  function layout() {
    var cum = 0;
    VALUES.forEach(function (v, i) {
      var frac = v / 100;
      var segLen = CIRC * frac;
      segs[i].style.strokeDasharray = segLen.toFixed(2) + ' ' + (CIRC - segLen).toFixed(2);
      segs[i].style.strokeDashoffset = (-cum).toFixed(2);
      segs[i].style.setProperty('--delay', (cum / CIRC * 60) + '%');
      cum += segLen;
    });
  }

04Sparkline Loading Whose Rows Pulse in a Wave

Each list row starts with a flat dashed placeholder, and once values arrive a wave delay computed from the row index reveals the real sparklines from top to bottom. A place in a list of items or metrics that previews each row's trend.

Sparkline Loading Whose Rows Pulse in a Wave
Open in a new tab

04 · Sparkline Loading Whose Rows Pulse in a Wave — Measured area 2.563% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

--lengetTotalLengthanimationDelay
  // Calculation layer: turns the row index (i) into a wave delay — the further down a row is, the later its real line appears
  function rowDelay(i) { return i * ROW_WAVE_MS; }

  reals.forEach(function (real, i) {
    var len = Math.round(real.getTotalLength());
    var row = real.closest('.cw__row');
    var flat = row.querySelector('.cw__flat');
    real.style.setProperty('--len', String(len));
    real.style.strokeDasharray = String(len);
    real.style.strokeDashoffset = String(len);
    real.style.animationDelay = rowDelay(i) + 'ms';
    flat.style.animationDelay = rowDelay(i) + 'ms';
    row.style.animationDelay = rowDelay(i) + 'ms';
  });

05Chart Loading Whose Cells Fade In by Distance

Each cell fades in with a delay computed from its row-plus-column distance from the origin cell, so the reveal spreads diagonally from the top-left corner before the real metric values land on top. A report screen where the axes should already be in place even if the values arrive late.

Chart Loading Whose Gridlines Fade In by Distance
Open in a new tab

05 · Chart Loading Whose Gridlines Fade In by Distance — Measured area 28.608% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

--delaytransform-originsetTimeout
  // Calculation layer: ranks a cell by its (row + column) distance from the origin — the reveal spreads diagonally from the top-left corner
  function cellOrder(row, col) { return row + col; }

  cells.forEach(function (cell) {
    var row = Number(cell.getAttribute('data-row'));
    var col = Number(cell.getAttribute('data-col'));
    var delay = cellOrder(row, col) * STEP_MS;
    cell.style.setProperty('--delay', delay + 'ms');
  });

06Multi-Series Loading Where Each Line Settles With a Damped Cascade

Several series skeletons appear together, then settle into their real values one by one on a delay computed by multiplying the series index into a damped, decaying sum, so nothing snaps into place all at once. A place where a comparison chart overlaying several metrics receives its values.

Multi-Series Loading Whose Each Line Settles With a Damped Cascade
Open in a new tab

06 · Multi-Series Loading Whose Each Line Settles With a Damped Cascade — Measured area 3.492% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

--lengetTotalLengthanimationDelay
  // Calculation layer: computes each series' delay as a damped (geometric) cumulative sum — later series get a shrinking gap, so the cascade settles down
  function seriesDelay(i) {
    var total = 0;
    for (var k = 1; k <= i; k += 1) { total += STEP_MS * Math.pow(DAMPING, k - 1); }
    return Math.round(total);
  }

  series.forEach(function (path, i) {
    var len = Math.round(path.getTotalLength());
    path.style.setProperty('--len', String(len));
    path.style.strokeDasharray = String(len);
    path.style.strokeDashoffset = String(len);
    path.style.animationDelay = seriesDelay(i) + 'ms';
  });

07Heatmap Loading Whose Cells Fill in a Diagonal Wave

Gray cells sit in a grid first, and once values arrive each cell's delay is computed from its Euclidean distance to the top-right corner divided by a wave speed, filling the grid in a diagonal wave. A place where a calendar heatmap or correlation table holds its cells before values load.

Heatmap Loading Whose Cells Fill in a Diagonal Wave
Open in a new tab

07 · Heatmap Loading Whose Cells Fill in a Diagonal Wave — Measured area 21.908% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

animationDelaygetAnimationstransform-origin
  // Calculation layer: treats the top-right corner as the wave's origin and divides the Euclidean distance by the wave speed to get the delay
  function waveDelayMs(row, col) {
    var dx = (COLS - 1 - col);
    var dy = row;
    var distance = Math.sqrt(dx * dx + dy * dy);
    return Math.round((distance / WAVE_SPEED) * 1000);
  }

08Chart Loading That Dissolves Into an Empty State on Failure

The panel cycles between a loading skeleton, a failed state, an empty state, and the real bars, and a manual retry waits longer each time using an exponential backoff computed from the retry count. A spot that invites a retry when a chart's values failed to come in.

Chart Loading That Dissolves Into an Empty State on Failure
Open in a new tab

08 · Chart Loading That Dissolves Into an Empty State on Failure — Measured area 7.962% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

matchMediaclassListsetTimeout
  function reduceMotion() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

  // Calculation layer: computes an exponential backoff wait from the retry count (attempt) — the more failures repeat, the longer the wait before the next try
  function backoffDelay(n) { return Math.round(BASE_BACKOFF_MS * Math.pow(BACKOFF_FACTOR, n)); }

  function showState(name) {
    Object.keys(states).forEach(function (key) {
      states[key].classList.toggle('is-active', key === name);
    });
    panel.setAttribute('aria-busy', name === 'loading' ? 'true' : 'false');
  }

09Chart Loading That Skips Computation and Swaps Instantly Under Reduced Motion

When the screen prefers reduced motion, the same delay formula is multiplied by a flag that collapses every bar's entrance delay to zero, so the skeleton swaps straight over to the real values instead of staggering in. An accessibility setting for users sensitive to vestibular issues or distraction.

Chart Loading That Skips Computation and Swaps Instantly Under Reduced Motion
Open in a new tab

09 · Chart Loading That Skips Computation and Swaps Instantly Under Reduced Motion — Measured area 9.878% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

--phasematchMediatransform-origin
  // Calculation layer: multiplies the same formula by a damping factor (reduced) — under reduced motion every delay collapses to zero
  function barDelay(i, reduced) { return reduced ? 0 : i * STAGGER_MS; }

  normalBars.forEach(function (bar, i) {
    bar.style.setProperty('--real', String(VALUES[i] / 100));
    bar.style.setProperty('--phase', barDelay(i, false) + 'ms');
  });
  reducedBars.forEach(function (bar, i) {
    bar.style.setProperty('--real', String(VALUES[i] / 100));
    bar.style.setProperty('--phase', barDelay(i, true) + 'ms');
  });

Where values change the UI — delay formulas, diagonal wave, damping

01 — STAGGER_MS

This constant is the per-column multiplier inside barDelay(i) = i * STAGGER_MS — not the bar's height itself, just how far apart each bar's entrance sits from its neighbor's.

06 — DAMPING

This is the base of the geometric term seriesDelay sums across the series index (STEP_MS · DAMPING^(k-1)) — not the step size itself, just how quickly the added gap between series shrinks as the cascade goes on.

07 — WAVE_SPEED

This is the divisor waveDelayMs applies to a cell's Euclidean distance from the top-right corner — not the distance itself, just how many grid cells the wave crosses per second.

Variant Changed value What it feels like
STAGGER_MS 70 → 30 Bar stagger delay drops from 70ms to 30ms Bars settle into their real height in a tighter burst instead of a visible left-to-right march, since barDelay(i) = i * STAGGER_MS keeps the same shape on a smaller constant
DAMPING 0.55 → 0.75 Cascade damping rises from 0.55 to 0.75 The geometric decay falls off more slowly, widening the time gap between each series settling into its real value
WAVE_SPEED 6 → 3 Diagonal wave speed drops from 6 to 3 The same distance now produces twice the delay, so the heatmap fills along its diagonal more slowly

Where it breaks — accessibility, reduced-motion, keyboard focus

All nine patterns share a baseline: the loading region carries role="img" and aria-busy (or aria-live="polite" on the two state-driven panels, 08 and 09), every reload or retry control answers both click and keydown, and prefers-reduced-motion is read with matchMedia rather than left to a CSS media query alone.

  • 01 — .cb__bar skips its transform/opacity transition and jumps straight to scaleY(var(--real)) at full opacity, so every bar shows its real height at once with no stagger.
  • 02 — the flat placeholder is hidden immediately (opacity: 0, animation off) and the real path's stroke-dashoffset is forced to 0 with !important, so the line appears fully drawn with no unspooling.
  • 03 — the spinning ring disappears immediately and every segment appears at full scale and opacity together, with no angle-based stagger between them.
  • 04 — each row skips its wave transform, the flat placeholders are hidden, and every real sparkline's dash offset is forced to 0, so all rows show their finished line at once.
  • 05 — the skeleton cells are hidden and the real metric values appear at full scale immediately, with no row-plus-column ordering left to see.
  • 06 — the flat placeholders are hidden and every series path's dash offset is forced to 0 at once, so the cascade collapses into a single simultaneous reveal.
  • 07 — the skeleton cells are hidden and every heatmap cell shows its real color at once, with no diagonal wave to trace.
  • 08 — .cx__state's transition is turned off, so the loading/fail/empty/success swap happens instantly; backoffDelay's exponential wait between retries keeps running exactly as before, since that timing lives in JavaScript, not CSS.
  • 09 — this pattern's entire subject is the reduced-motion path itself: barDelay(i, true) already returns 0 for every bar, so flipping the media query on is what makes the difference this whole pattern exists to show.

Opening the zip with the password tx28eqn3 and reading all nine files side by side is the quickest way to see which pattern keeps a CSS transition alive under reduced motion and which one instead forces the value straight to its target.

Three variants and accessibility

The table above already covers the three confirmed variants — STAGGER_MS, DAMPING, and WAVE_SPEED — and each one swaps exactly that single constant while every listener, aria-* attribute, and reduced-motion branch stays identical to its base pattern. Since a variant's index.html is the base file with only that one number edited, none of the accessibility behavior documented above needs re-checking per variant.

Where can I find chart loading animation examples?

Most bar chart loading animation searches turn up either a spinner overlay dropped on top of an empty canvas or a single skeleton block with no per-bar or per-cell timing behind it at all. The nine patterns above sit in between: plain buttons, plain click/keydown listeners, and one small named calculation function apiece, all copyable straight out of the zip above. Dashboards and reporting screens are the obvious home for this shape, but the same reveal-once-values-arrive structure works for any widget that has to sit empty for a moment before real numbers land — a KPI tile, a status panel, a small embedded report. None of the nine reach for a charting library or a CDN script to get there.

FAQ

Why does pattern 06 have the highest average intensity of the nine?

Settling each series into its real value repaints that series' entire stroke length and dash offset in one step, and because seriesDelay staggers the series only slightly (the damping term shrinks fast), several of those full-path repaints land close together in time rather than spreading thinly across the whole reveal.

Does pattern 07's diagonal wave measure straight-line distance or grid steps?

Straight-line distance — waveDelayMs(row, col) takes the square root of the squared horizontal and vertical offsets from the top-right corner (a true Euclidean distance), then divides that by WAVE_SPEED, rather than simply adding the row and column together.

Is this chart skeleton ui pattern set safe to drop into a real dashboard?

The click/keydown listeners, the aria-busy/aria-live sync, and each calculation function transfer directly — swap the demo's fixed VALUES array for whichever real data your chart already fetches, and the timing math needs no changes. The one thing every pattern here skips on purpose is caching a failed request, so add that yourself before shipping it.

Sources: MDN — prefers-reduced-motion · MDN — aria-busy · MDN — stroke-dasharray · MDN — Web Animations API · MDN — role="progressbar"

Related reading: 9 CSS Skeleton Loading Screens, Copy-Paste Ready · CSS Chart Design: 9 SVG Graphs, No Library · JavaScript Chart Drawing, 9 Ways — No Library — those two posts draw a chart's shape from scratch with SVG or canvas, while every pattern above instead handles the loading transition before a chart's real values are ready.

Enter the archive password

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