Animating SVG Charts and Data Visualisations
Part of Animating SVG with CSS in Core CSS Animation Fundamentals.
The problem
A dashboard renders bar and line charts as inline SVG. Product wants the bars to grow in on load, the lines to draw left to right, and values to glide when the date range changes. A charting library could do it, but it animates by rewriting SVG attributes from JavaScript every frame, which is heavy for a page with eight charts and freezes whenever data is being processed.
CSS can handle most chart motion — if the chart is rendered at its final values and the animation is layered on top, rather than the animation being how the chart gets its values.
Root cause analysis: attributes versus styles
A bar in an SVG chart is typically a rect with y and height attributes computed from the data. Animating those attributes means changing geometry, which forces the SVG to be repainted each frame and, when done from script, runs on the main thread.
Scale the finished bar instead. Render the rect at its final height, then animate scale: 1 0 to scale: 1 1 with transform-origin at the bar’s baseline. With transform-box: fill-box, transform-origin: 50% 100% is the bottom of each bar regardless of its position in the chart. The geometry is painted once, and for charts that are their own layer the growth can be composited.
Draw lines with dash offsets. A line series is a path. With pathLength="1", stroke-dasharray: 1 and stroke-dashoffset animated from 1 to 0 draws it from the first data point to the last, independent of how long the path is. This repaints each frame, so do it once on entrance, not continuously — the technique in drawing SVG lines with stroke-dashoffset.
Data updates are value transitions. When the range changes, bars need new heights. Setting a registered custom property such as --value on each bar and deriving scale from it lets CSS transition between old and new values: scale: 1 calc(var(--value) / var(--max)). The bar is rendered at full chart height once, and each update is a scale change.
Step-by-step resolution
Production code pattern
<svg class="chart" viewBox="0 0 320 180" role="img" aria-labelledby="chart-title">
<title id="chart-title">Weekly sign-ups, Monday to Friday</title>
<g class="bars" style="--max: 400">
<rect x="20" y="20" width="40" height="140" style="--value: 180; --i: 0"/>
<rect x="80" y="20" width="40" height="140" style="--value: 260; --i: 1"/>
<rect x="140" y="20" width="40" height="140" style="--value: 340; --i: 2"/>
<rect x="200" y="20" width="40" height="140" style="--value: 220; --i: 3"/>
<rect x="260" y="20" width="40" height="140" style="--value: 390; --i: 4"/>
</g>
<path class="trend" pathLength="1" d="M40 110 L100 86 L160 62 L220 96 L280 30"/>
</svg>
@property --value { syntax: "<number>"; inherits: false; initial-value: 0; }
.bars rect {
fill: var(--color-primary-600);
transform-box: fill-box;
transform-origin: 50% 100%; /* grow from each bar's baseline */
scale: 1 calc(var(--value) / var(--max));
transition: --value 400ms cubic-bezier(0.2, 0.8, 0.2, 1);
animation: bar-grow 500ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: min(calc(var(--i) * 60ms), 300ms);
}
@keyframes bar-grow { from { scale: 1 0; } }
.trend {
fill: none;
stroke: var(--color-accent-700);
stroke-width: 2.5;
stroke-dasharray: 1;
animation: trend-draw 900ms ease-in-out 300ms both;
}
@keyframes trend-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) {
.bars rect { animation: none; transition: none; }
.trend { animation: none; }
}
// Range change: only --value changes; CSS transitions the bars.
function updateBars(values) {
chart.querySelectorAll('.bars rect').forEach((rect, i) => {
rect.style.setProperty('--value', values[i]);
});
}
Rendering Impact: style per frame for the
--valuetransition, then composite for the scale; paint per frame for the one-off line draw. Bars are painted once at full height; neither the entrance nor data updates rewrite geometry.
The --max value is shared on the group so every bar scales against the same axis. When the new range has a different maximum, update --max too — but do not transition it, or every bar animates twice with different curves.
Keeping the data accessible
Animation must never be the only path to the numbers. An SVG with role="img" and a title summarises the chart for screen readers but does not expose individual values; provide the data in a table, visually hidden or behind a “view as table” toggle. Axis labels and value labels rendered as SVG text should appear at their final positions immediately rather than animating in after the bars, so a user who pauses or has animations removed sees complete information.
Verification checklist
Constraints and trade-offs
- Scaling a rect also scales its stroke; use fill-only bars or
vector-effect: non-scaling-stroke. - Registered custom property transitions cost style work per frame per bar; hundreds of bars updating together add up.
stroke-dashoffsetdraws repaint each frame; do not loop them.- Rounded bar corners scale with the bar and distort mid-animation.
- Negative values need an origin at the zero line rather than the bar’s bottom.
Frequently asked questions
Why do my bars grow from the top of the chart?
The SVG default transform-box uses the chart’s viewBox, so the origin is in chart coordinates. Set transform-box: fill-box and transform-origin: 50% 100% on each bar.
Can CSS animate a chart when data changes?
Yes, if data drives a registered custom property. Transition that property and derive the bar’s scale from it, so CSS interpolates between old and new values.
Should chart labels animate too?
Keep labels and values at their final positions. Animating them delays the information and hides it from anyone who does not wait for the motion.
Is a charting library still needed?
For layout, scales and axes, often yes. The animation layer can still be CSS, applied to whatever SVG the library outputs.
Related
- Animating SVG with CSS — the parent topic
- Staggering Animations with sibling-index() — delays for series and categories
- Registering Custom Properties with @property — typed values that transition