I had a bar chart race sitting in a private repo for over five years. A coding challenge from 2020 or so — built it, moved on, forgot about it. When I started building the toolkit on varstatt.com — free browser-based dev tools — it seemed like an obvious candidate to resurrect.
The new version would be React with SVG, part of a suite: bar chart race, line chart race, area chart race, bubble chart race. Same idea, four visualizations. Upload a CSV, watch the data animate.
Every animation technique I reached for broke in a way I didn't expect.
CSS Transitions Do Nothing on Geometric Attributes
First attempt on the line chart: CSS transitions on SVG elements. transition: cx 300ms ease, cy 300ms ease on the <circle> dots tracking data points. Expected smooth interpolation between positions.
The dots snapped. No animation. Chrome, Firefox, same result.
/* This does nothing useful */
circle {
transition: cx 300ms ease, cy 300ms ease;
}
CSS transitions animate CSS properties. cx, cy, r, points are not CSS properties — they're SVG attributes. They live in the DOM, but the browser's animation engine doesn't see them. You can change them from JavaScript and the element moves, but there's no interpolation. It jumps.
transform and opacity work because those are actual CSS properties that SVG elements happen to support. Everything that describes SVG geometry — positions, sizes, path data — sits outside that system.
Two Animation Systems on the Same Property
The bar chart race had horizontal bars with CSS transitions on top and width. I set transition: top 1000ms ease-out, width 1000ms ease-out and advanced frames with setInterval. That worked.
Then I switched playback to requestAnimationFrame for continuous interpolation — a float position updating at ~60fps instead of integer jumps every second.
The bars turned jittery. Every RAF tick (~16ms) set a new top value. Each value restarted the 1000ms CSS transition before the previous one completed. The browser's transition engine was fighting the RAF loop. Two animation systems controlling the same property, neither finishing.
// RAF updates position every ~16ms
const top = rank * BAR_HEIGHT;
// CSS transition: "top 1000ms ease-out"
// Every 16ms: cancel current transition, start new 1000ms transition
// Result: jittery mess
The fix was to remove every CSS transition from every element that RAF touches. Bar positions, widths, SVG coordinates, label positions — all computed directly from the playback float.
// RAF computes position directly — no CSS transition
const interpolatedRank = prevRank + (nextRank - prevRank) * frac;
const top = interpolatedRank * BAR_HEIGHT;
// style={{ top, transition: 'none' }}
CSS transitions and requestAnimationFrame are competing strategies. They solve the same problem differently. Layering both on the same property means neither works. I ended up with zero CSS transitions on animated properties across all four chart types.
Colors That Follow Position Instead of Identity
Bubble chart. Bubbles sorted by value each frame so the largest renders on top (correct z-order). Colors assigned by array index after sorting.
Frame 1: Python is biggest, gets index 0, gets blue. Frame 2: JavaScript overtakes Python, gets index 0, gets blue. Python drops to index 1, turns orange. Every frame where the lead changes, half the bubbles swap colors.
// before: color by sorted position
const sorted = [...items].sort((a, b) => b.value - a.value);
sorted.forEach((item, i) => {
item.color = palette[i % palette.length];
});
The fix: build a color map keyed by series name at parse time, before any sorting happens.
const colorMap = useMemo(() => {
const map = {};
data.seriesNames.forEach((name, i) => {
map[name] = palette[i % palette.length];
});
return map;
}, [data.seriesNames, palette]);
data.seriesNames preserves the original CSV column order. It never changes during playback. Sorting for z-order still happens, but it only affects render order, not color. Any visualization where items reorder needs visual properties assigned by identity, never by current array position.
Ranks That Re-Sort Every Tick
Same bar chart race. I was re-sorting bars by their interpolated value on every RAF tick. Values cross each other mid-frame constantly — Python at 11.83 overtakes Java at 11.81 for one tick, then Java is back on top the next. The bars flickered between positions 60 times a second.
The fix: compute sort order only at whole frame boundaries, store it in a pre-computed array, then interpolate rank positions as floats between frames.
const frameRanks = useMemo(() =>
data.frames.map(frame => {
const sorted = data.seriesNames
.map(name => ({ name, value: frame[name] }))
.sort((a, b) => b.value - a.value);
const ranks = {};
sorted.forEach((s, i) => { ranks[s.name] = i; });
return ranks;
}), [data]);
// interpolate rank as a float
const rank = (currentRanks[name] ?? idx) * (1 - frac)
+ (nextRanks[name] ?? idx) * frac;
const top = rank * barHeight;
A bar sliding from position 3 to position 1 moves smoothly over the full frame duration instead of jumping. No flickering.
Curves That Rewrite Their Own History
The line and area charts used Catmull-Rom splines. The animation draws a line progressively — like a pen moving across the screen. Curves looked great.
The problem showed up immediately: as the animation advanced and new points entered the spline, the entire line wiggled. Segments already "drawn" shifted into new positions on every frame.
Catmull-Rom computes each segment's control points from the tangent at its endpoints, and the tangent at any point depends on its neighbors. Add a new neighbor, all the tangents change. Feed completed points into the spline function as the animation progresses and every frame recalculates every segment. The old part of the curve is never stable.
The fix split the work into two memos with different dependency arrays.
// Phase 1: compute ALL segments from full dataset, once
const stableGeometry = useMemo(() => {
const allPoints = data.frames.map((f, i) => ({
x: xScale(i), y: yScale(f.values[seriesName])
}));
const segments = precomputeSegments(allPoints);
return { allPoints, segments };
}, [data, width, height]);
// Phase 2: reveal progressively, split active segment with de Casteljau
const chartData = useMemo(() => {
const { segments } = stableGeometry;
const wholeIdx = Math.floor(position);
const frac = position - wholeIdx;
let d = segments.slice(0, wholeIdx).map(s => s.pathData).join('');
if (frac > 0 && segments[wholeIdx]) {
const partial = splitBezierAt(segments[wholeIdx], frac);
d += partial.pathData;
}
return d;
}, [stableGeometry, position]);
Pre-compute the full curve from all data points. Completed segments render byte-identical every frame. The active segment gets split at the exact fractional position using de Casteljau subdivision. Historical geometry never depends on current playback position.
The Common Assumption
Each problem came from expecting SVG elements to behave like DOM elements when animated. CSS transitions ignore geometric attributes. RAF and transitions fight over the same values. Array indices aren't stable identifiers when sort order changes. Spline algorithms that look local are global.
The fix was the same every time: compute everything yourself, from one source of truth. Zero CSS transitions on animated properties, all positions derived from a single playback float.
The four chart tools are part of the varstatt.com/toolkit — free, browser-based, no sign-up: bar chart race, line chart race, area chart race, bubble chart race. The old repo from 2020 bears no resemblance to what shipped.
Top comments (0)