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

9 Graph Animation CSS Pickers With Live Recompute

Move a bar by pixel and the graph drifts from its value. So nine pickers recompute: bar height from a bucket value, a gauge arc filled by ratio, a sparkline

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

None of the nine reads from a server, a clock, or localStorage, and none imports a chart library, WebGL, or a CDN script. Every series is a fixed array seeded once at load — the histogram's twelve bucket heights, the radar's five axis baselines, the waveform's forty bars — so what moves on drag is only ever the shape derived from that seed plus the current pointer value. Axis and legend labels stay neutral (A through E, bucket indices) on purpose, so the demos read the same whether the visitor is comparing chart animation css ideas for a dashboard or a settings panel.

Why This Differs From a Dashboard Reveal or a Value-Only Picker

Post 264's dashboard charts already hold real numbers — dragging, hovering, or clicking only recounts or highlights data that was there before the interaction. Post 224's pickers move a thumb or a swatch and update a number or a color, but no chart lives on the page. This post's nine pickers sit between those two: dragging the thumb is the only input, and the graph itself — the bar heights, the arc length, the polyline points — is recomputed from that one number on every frame. There's nothing to reveal, because nothing existed before the drag.

What Recomputes When the Pointer Moves

Each of the nine wires the same input surface: pointerdown/pointermove/pointerup for dragging, arrow keys for keydown stepping, and a resize listener to keep the drag track aligned with the rendered box. On every accepted move, a requestAnimationFrame callback reads the current value and calls one recompute function specific to that pattern — a bucket index and fill fraction for the histogram, a sweep angle for the gauge and orbit dial, a trail array shift for the sparkline, a segment resplit for the donut and treemap, five axis lengths for the radar, an active-bar cutoff for the waveform, a color ratio for the heatmap. The DOM write that follows is always the smallest one that reflects the new number: a handful of <rect> heights, one <path d> string, or a few class toggles — never a full redraw of the SVG or canvas.

Nine Patterns, Measured by Moved Area and Frame Count

The numbers below aren't estimated from watching the demos — they come from run/398/demo/0N-*/측정.json, written by the same pixel-diff pass every other post in this pillar uses: each pattern renders its full drag range, and consecutive frames are diffed to get a percentage of the 480×300 canvas that changed and an average diff intensity where it changed. That's why "cumulative area" differs so much between something like the sparkline (a polyline redrawing across most of the chart width) and the radar (five short segments near the center) — it's measuring where pixels actually moved, not guessing from the CSS.

# Pattern Cumulative area Avg intensity Moved frames
01 Histogram buckets 23.2% 145.2 23/23
02 Radial gauge arc 10.0% 137.6 23/23
03 Sparkline trail 32.8% 167.5 23/23
04 Donut resplit 12.2% 150.1 23/23
05 Radar polygon 9.6% 155.4 23/23
06 Waveform scrub 23.1% 174.5 23/23
07 Orbit dial arc 9.6% 187.7 23/23
08 Heatmap cell 15.6% 73.7 23/23
09 Treemap blocks 24.6% 117.4 23/23

All nine moved on every one of the 23 frame-to-frame comparisons across the drag range — there's no pattern here that only animates near the ends of the track.

Code Per Pattern, Same as the graph animation css codepen Tabs Show

Each block below is pulled straight from that item's index.html — the same HTML/CSS/JS tabs the live demo's codepen-style embed shows, not a simplified rewrite. If you came here from a graph animation css codepen search, this is the part meant to answer it directly: real recompute functions, not just the CSS transition.

01Histogram Buckets Redistribute Around One Value

Dragging moves a value from 0–100; the picker maps it to one of twelve buckets and only that bucket's target height changes — the other eleven hold their baseline.

Picker Whose Histogram Bars Recompute Height With Every Drag
Open in a new tab

01 · Picker Whose Histogram Bars Recompute Height With Every Drag — Measured area 23.203% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BUCKET_COUNTPEAK_BOOSTCHART_H
function bucketOf(v) {
  var idx = Math.floor(v / 100 * BUCKET_COUNT);
  return Math.max(0, Math.min(BUCKET_COUNT - 1, idx));
}

function fracIn(v, bucket) {
  var raw = v / 100 * BUCKET_COUNT - bucket;
  return Math.max(0, Math.min(1, raw));
}

// only the bucket the value falls in recomputes its target height — the rest keep the baseline
function recompute() {
  var b = bucketOf(value);
  var f = fracIn(value, b);
  for (var i = 0; i < BUCKET_COUNT; i++) {
    targetH[i] = BASE[i];
  }
  targetH[b] = Math.min(0.96, BASE[b] + PEAK_BOOST * (0.4 + 0.6 * f));
  return b;
}

02Radial Gauge Arc Length Tracks a Ratio

The filled arc's endpoint angle is a straight function of the ratio, so the arc's length — not just its rotation — is what the drag changes.

Picker Whose Radial Gauge Arc Fills to Match Its Value
Open in a new tab

02 · Picker Whose Radial Gauge Arc Fills to Match Its Value — Measured area 9.973% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STARTENDMath.atan2
function arcPath(cx, cy, r, a0, a1) {
  if (Math.abs(a1 - a0) < 0.5) return '';
  var s = polar(cx, cy, r, a0), e = polar(cx, cy, r, a1);
  var large = (a1 - a0) > 180 ? 1 : 0;
  return 'M ' + s.x + ' ' + s.y + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + e.x + ' ' + e.y;
}

function angleOf(v) { return START + (SWEEP * v) / 100; }

function paint() {
  track.setAttribute('d', arcPath(CX, CY, R, START, END));
  fill.setAttribute('d', arcPath(CX, CY, R, START, angleOf(current)));
  var p = polar(CX, CY, R, angleOf(current));
  grab.style.left = (p.x / 480 * 100) + '%';
  grab.style.top = (p.y / 300 * 100) + '%';
  var rv = Math.round(current);
  numEl.textContent = rv + '%';
  valEl.textContent = String(rv);
  grab.setAttribute('aria-valuenow', String(rv));
  grab.setAttribute('aria-valuetext', 'Level ' + rv + '%');
}

03Sparkline Trail Traces Every Passed Value

Every accepted drag position pushes onto a fixed-length trail array; the polyline is rebuilt from that array each frame, so the line's shape is a short history, not a single point.

Picker That Leaves Its Drag Path as a Sparkline Trail
Open in a new tab

03 · Picker That Leaves Its Drag Path as a Sparkline Trail — Measured area 32.755% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TRAIL_LENGTHCHART_WSTEP
function buildPoints() {
  var step = CHART_W / (TRAIL_LENGTH - 1);
  var pts = [];
  for (var i = 0; i < TRAIL_LENGTH; i++) pts.push([i * step, yOf(trail[i])]);
  return pts;
}

function paint() {
  var pts = buildPoints();
  var d = 'M ' + pts[0][0] + ' ' + pts[0][1];
  for (var i = 1; i < pts.length; i++) d += ' L ' + pts[i][0] + ' ' + pts[i][1];
  lineEl.setAttribute('d', d);
  areaEl.setAttribute('d', d + ' L ' + pts[pts.length - 1][0] + ' ' + CHART_H + ' L ' + pts[0][0] + ' ' + CHART_H + ' Z');
  var last = pts[pts.length - 1];
  dotEl.setAttribute('cx', String(last[0]));
  dotEl.setAttribute('cy', String(last[1]));
  var rv = Math.round(current);
  valEl.textContent = String(rv);
  grab.style.left = value + '%';
  grab.setAttribute('aria-valuenow', String(Math.round(value)));
  grab.setAttribute('aria-valuetext', 'Value ' + Math.round(value));
}

04Donut Resplit Keeps Three Segments in Ratio

One segment grows to the dragged share; the other three shrink but keep their relative sizes to each other, via a fixed ratio array applied to whatever's left over.

Picker Whose Donut Segment Grows With Its Value Ratio
Open in a new tab

04 · Picker Whose Donut Segment Grows With Its Value Ratio — Measured area 12.204% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LABELSOTHER_RATIOSTROKE
function polar(cx, cy, r, deg) {
  var rad = deg * Math.PI / 180;
  return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}

function arcPath(cx, cy, r, a0, a1) {
  if (a1 - a0 < 0.6) return '';
  var s = polar(cx, cy, r, a0), e = polar(cx, cy, r, a1);
  var large = (a1 - a0) > 180 ? 1 : 0;
  return 'M ' + s.x + ' ' + s.y + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + e.x + ' ' + e.y;
}

function recompute(v) {
  var rest = 100 - v;
  pct = [v, rest * OTHER_RATIO[0], rest * OTHER_RATIO[1], rest * OTHER_RATIO[2]];
}

05Radar Polygon Redraws All Five Axes at Once

Only one axis is under the pointer, but the polygon path is rebuilt from all five axis lengths every frame, so the whole shape visibly reflows around the one changed point.

Picker Whose Radar Shape Redraws as You Drag Each Axis Point
Open in a new tab

05 · Picker Whose Radar Shape Redraws as You Drag Each Axis Point — Measured area 9.604% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

AXIS_COUNTPHASESPERIODS
function paint() {
  var d = '';
  for (var i = 0; i < AXIS_COUNT; i++) {
    var p = polar(CX, CY, R * (current[i] / 100), angleOf(i));
    d += (i === 0 ? 'M ' : 'L ') + p.x + ' ' + p.y;
    pointEls[i].setAttribute('cx', String(p.x));
    pointEls[i].setAttribute('cy', String(p.y));
  }
  polyEl.setAttribute('d', d + ' Z');
  var rv = Math.round(current[0]);
  valEl.textContent = String(rv);
  grab.style.left = ((values[0] - MIN_V) / (MAX_V - MIN_V) * 100) + '%';
  grab.setAttribute('aria-valuenow', String(Math.round(values[0])));
  grab.setAttribute('aria-valuetext', 'Axis A ' + Math.round(values[0]));
  var rows = '';
  for (var j = 0; j < AXIS_COUNT; j++) rows += '<tr><th>' + LABELS[j] + '</th><td>' + Math.round(current[j]) + '</td></tr>';
  srBody.innerHTML = rows;
}

06Waveform Scrub Marks Bars as Played

The drag position maps to a bar index; every bar before that index gets an is-past class, so scrubbing looks like a playhead moving across a fixed waveform rather than bars growing or shrinking.

Picker That Fills Waveform Bars Up to the Scrubbed Position
Open in a new tab

06 · Picker That Fills Waveform Bars Up to the Scrubbed Position — Measured area 23.116% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAR_WHEIGHTSGAP
function buildBars() {
  var html = '';
  for (var i = 0; i < N; i++) {
    var h = HEIGHTS[i] * CHART_H;
    var x = i * BAR_W + GAP / 2;
    var w = Math.max(1, BAR_W - GAP);
    html += '<rect class="wf__bar" id="wb' + i + '" x="' + x + '" y="' + (CHART_H - h) + '" width="' + w + '" height="' + h + '"></rect>';
  }
  barsG.innerHTML = html;
  for (var j = 0; j < N; j++) barEls.push(document.getElementById('wb' + j));
}

function paint() {
  var activeIdx = Math.round((current / 100) * N);
  for (var i = 0; i < N; i++) barEls[i].classList.toggle('is-past', i < activeIdx);
  var x = (current / 100) * 480;
  headline.setAttribute('x1', String(x));
  headline.setAttribute('x2', String(x));
  var rv = Math.round(current);
  valEl.textContent = String(rv);
  grab.style.left = value + '%';
  grab.setAttribute('aria-valuenow', String(Math.round(value)));
  grab.setAttribute('aria-valuetext', 'Playback position ' + Math.round(value) + '%');
}

07Orbit Dial Arc Lags Behind the Raw Angle

The dot's target angle jumps straight to the pointer, but the drawn arc's endpoint eases toward it by ARC_LERP each frame, so the arc visibly trails the dot instead of snapping with it.

Picker That Turns Its Value Into an Orbit's Arc Length
Open in a new tab

07 · Picker That Turns Its Value Into an Orbit's Arc Length — Measured area 9.642% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ARC_LERPMath.atan2clip-path
function arcPath(cx, cy, r, a0, a1) {
  if (a1 - a0 < 0.6) return '';
  var s = polar(cx, cy, r, a0), e = polar(cx, cy, r, a1);
  var large = (a1 - a0) > 180 ? 1 : 0;
  return 'M ' + s.x + ' ' + s.y + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + e.x + ' ' + e.y;
}

function angleOf(v) { return START + (v / 100) * 360; }

function paint() {
  var deg = angleOf(current);
  arcEl.setAttribute('d', arcPath(CX, CY, R, START, deg));
  var p = polar(CX, CY, R, deg);
  dotEl.setAttribute('cx', String(p.x));
  dotEl.setAttribute('cy', String(p.y));
  grab.style.left = (p.x / 480 * 100) + '%';
  grab.style.top = (p.y / 300 * 100) + '%';
  var rd = Math.round(deg - START);
  valEl.textContent = String(rd);
  grab.setAttribute('aria-valuenow', String(Math.round(current)));
  grab.setAttribute('aria-valuetext', 'Orbit ' + rd + ' degrees');
}

08Heatmap Cell Recolors by Intensity Ratio

The dragged value maps to a color ratio through the same colorFor() used to build the static legend cells, so the hero cell's color and the legend's fixed colors always come from one shared scale.

Picker Whose Value Sets a Heatmap Cell's Intensity
Open in a new tab

08 · Picker Whose Value Sets a Heatmap Cell's Intensity — Measured area 15.633% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RATIOSMAX_Vgrid-template-columns
function buildStatic() {
  var legendHtml = '';
  for (var i = 0; i < N; i++) {
    var cell = document.createElement('div');
    cell.className = 'hm__cell';
    cell.style.background = colorFor(RATIOS[i]);
    grid.appendChild(cell);
    legendHtml += '<li>Fixed cell ' + (i + 1) + ': ' + Math.round(RATIOS[i] * 100) + '%</li>';
  }
  legend.innerHTML = legendHtml;
}

function paint() {
  var ratio = current / MAX_V;
  hero.style.background = colorFor(ratio);
  var rv = Math.round(current);
  valEl.textContent = String(rv);
  grab.style.left = value + '%';
  grab.setAttribute('aria-valuenow', String(Math.round(value)));
  grab.setAttribute('aria-valuetext', 'Selected cell ' + Math.round(value) + '%');
}

09Treemap Blocks Resplit by Remaining Width

Growing block A shrinks the column left for B, C, and D; their heights are recomputed from what's left, not just their widths, so the whole block layout resplits rather than one block overlapping the rest.

Picker Whose Treemap Blocks Resize to Match Each Value
Open in a new tab

09 · Picker Whose Treemap Blocks Resize to Match Each Value — Measured area 24.647% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

OTHER_RATIOMIN_Vclip-path
  var rest = 100 - a;
  var b = rest * OTHER_RATIO[0];
  var c = rest * OTHER_RATIO[1];
  var d = rest * OTHER_RATIO[2];

  blkA.style.width = a + '%';
  blkA.style.height = '100%';
  blkA.style.left = '0%';
  blkA.style.top = '0%';

  var colW = 100 - a;
  var bH = rest > 0.001 ? (b / rest) * 100 : 0;
  var cH = rest > 0.001 ? (c / rest) * 100 : 0;

Where the Arc Patterns Break Into an Empty Path

arcPath() in the radial gauge, the donut, and the orbit dial all return an empty string — not a zero-length arc, not a single point, nothing — when the angle gap between start and end drops under a small threshold (0.5 degrees for the gauge, 0.6 for the donut and orbit). That's a real trap: at the very bottom of the gauge's range or the very start of a drag, the fill arc doesn't shrink to a thin sliver the way a CSS stroke-dasharray transition would — it disappears outright, because an SVG arc command can't express a near-zero sweep without the endpoint math producing something degenerate. If you're adapting one of these three for a product where "almost zero" needs to stay visible, the fix is a minimum-angle floor before the guard clause, not a smaller threshold.

Accessibility: What prefers-reduced-motion Actually Skips

All nine check prefers-reduced-motion on load and again before every drag, key press, or resize and, when it's set, skip the requestAnimationFrame easing entirely — the value jumps straight to its target and paint() (plus an aria-live announce) runs once, synchronously, instead of over several animated frames. <span class="pw-inline" id="pw">spguctjy</span> The download's zip keeps every pattern's reduced-motion branch intact, so testing that path doesn't require re-implementing it. Every dial and thumb also carries aria-valuenow and aria-valuetext — the sparkline reports "Value 62," the orbit dial reports "Orbit 140 degrees" — so a screen reader gets the current number even though the graph itself has no text alternative for its shape.

Where You'd Actually Use a Value-Driven Graph Picker

A settings screen for a video player is the clearest fit for the waveform scrub (item 06): instead of a bare range input, the played portion visibly marks itself against the track's shape, which is closer to what users already expect from a real player's seek bar. The histogram bucket picker (item 01) fits a "target one segment" control — picking which price bracket or age bucket to highlight in a report filter, where seeing the other buckets stay put confirms nothing else moved. The sparkline trail (item 03) suits a live-tuning knob, like a volume or brightness control, where showing the last several positions (not just the current one) helps a user see whether they're overshooting back and forth. None of the three need a charting library — each reads more like a chart range slider, a styled <input type="range"> with an SVG readout, than a reporting dashboard.

Three Variants, Same Nine Files

Variant Change Feel
01 — bucket count BUCKET_COUNT 12 → 24 finer buckets, each one narrower and less visually dominant when it peaks
03 — trail length TRAIL_LENGTH 48 → 96 a longer visible history, so the polyline reads as a slower-scrolling trace
07 — arc lerp ARC_LERP 0.18 → 0.08 the arc trails the dot more noticeably, closer to a heavier, laggier feel

FAQ

Does any of the nine fetch real data or store a value between visits?

No. Every series is a fixed array assigned once when the script runs, there's no fetch, no localStorage, and no Math.random — dragging only changes which part of that fixed array is highlighted or how it's summarized.

Why does the sparkline's cumulative area (32.8%) run so much higher than the radar's (9.6%)?

The sparkline's polyline and filled area redraw across nearly the full chart width on every frame, while the radar only redraws the five short segments nearest the axis being dragged — same 480×300 canvas, very different amount of it changing per frame.

Can I drop just one of the nine into an existing project instead of all nine?

Yes — each item's folder in the zip is self-contained (its own index.html, style.css, and inline <script>, with nothing shared between items), so copying one item's markup, CSS, and JS out is enough; nothing here references another item's IDs or classes.

Source: MDN — requestAnimationFrame, MDN — prefers-reduced-motion, MDN — Pointer events.

Related reading: Dashboard Chart Interactions, CSS Progress Bar Gauge Patterns, CSS Picker UI Patterns.

Enter the archive password

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