← blog

What SVG Animation Can Actually Do

A tour of animating diagrams without reaching for a GIF — attributes, motion paths, line drawing, sequencing, easing, and a systems diagram that explains itself while you watch.

Every diagram in my last few posts is a static SVG. That is usually right: a picture you can read at your own pace beats one that moves. But some things are motion — a request travelling through a retry stack, a queue draining, a cache filling — and drawing them still costs a reader the work of imagining time.

The reflex is to reach for a GIF. You don’t have to. SVG has animation built into the format, and it costs a few hundred bytes instead of a few hundred kilobytes.

There are three ways in, and this post uses the first two:

  • SMIL — animation tags inside the SVG (<animate>, <animateMotion>, <animateTransform>). Declarative, no JavaScript, works in an <img> tag.
  • CSS@keyframes in a <style> block inside the SVG. Everything CSS animation can do, including staggered delays.
  • JavaScript — for anything reader-driven. Out of scope here; that’s what a custom React component is for.

Both of the first two survive publishing and round-trip through the editor untouched.


01 · The simplest thing: animate one attribute#

<animate> sits inside the element it animates and changes one attribute over time. Here cx — the circle’s horizontal position — walks from one end to the other and back.

values is a list of stops, dur is how long the whole list takes, repeatCount='indefinite' loops forever.
<circle cy="40" r="7" fill="currentColor">
  <animate attributeName="cx" values="40;680;40" dur="4s" repeatCount="indefinite"/>
</circle>

That is the whole idea. Any animatable attribute works — r, opacity, fill, width, stroke-width. Two more <animate> tags in the same element run at the same time:

Three attributes animating at once, on different clocks — 2s, 3s and 5s — so the combination never quite repeats.

02 · Motion along a path#

A straight line is easy. For anything else, <animateMotion> takes a path and moves the element along it — and the path can be one you already drew.

clientserver
The dot follows the same curve the arrow is drawn from. One path string, used twice.
<animateMotion dur="3s" repeatCount="indefinite"
               path="M140,72 C280,10 440,130 580,72"/>

Add rotate="auto" and the element turns to face its direction of travel — which is how you animate an arrowhead rather than a dot.

rotate='auto' keeps the arrowhead pointing along the curve instead of sliding sideways.

03 · Drawing a line#

The workhorse. A dashed stroke whose dash is as long as the whole line, offset out of view, then pulled back in — the line appears to draw itself.

stroke-dasharray sets one dash the length of the path; animating stroke-dashoffset from that length to zero reveals it.
<path d="…" stroke-dasharray="760" stroke-dashoffset="760">
  <animate attributeName="stroke-dashoffset"
           values="760;0;0;760" dur="5s" repeatCount="indefinite"/>
</path>

The repeated 0;0 in the middle is a hold — the line stays drawn for a beat before erasing. Repeating a value is how you pause without a second animation.


04 · Transforms#

<animateTransform> animates rotate, scale, translate and skew. Rotation needs a centre, given as two extra numbers after the angle.

Left: rotation about its own centre. Middle: a pulse built from scale. Right: both, on one element, via additive='sum'.

Two transforms on one element normally fight — the second replaces the first. additive="sum" composes them instead. Note the circle scales from the origin unless you account for it; rotation takes its centre inline, scale does not.


05 · Sequencing — the part that makes diagrams explain themselves#

This is where SVG animation stops being decoration. begin can name another animation’s end, so steps run in order rather than all at once.

edgeauthhandlerdb
Each link draws, then its node lights in its own colour and stays lit. The last one flickers as the connection lands before settling. Nothing here knows about time — only about what comes before it.
<animate id="s1"begin="0s;s4.end+1s" fill="freeze"/>
<animate id="s2"begin="s1.end"       fill="freeze"/>
<animate id="s3"begin="s2.end"       fill="freeze"/>
<animate id="s4"begin="s3.end" fill="freeze"/>

<!-- each node's glow: light on cue, and clear when the sequence restarts -->
<rect …>
  <set    attributeName="opacity" to="0"                begin="s1.begin"/>
  <animate attributeName="opacity" to="0.14" dur="0.25s" begin="s2.end"   fill="freeze"/>
</rect>

Three things carry the weight here:

  • begin="s1.end" — a step starts when the one before it finishes. Change a duration anywhere and the rest still line up; nothing is hand-timed.
  • fill="freeze" — hold the final value instead of snapping back. Without it, each segment vanishes the moment it finishes and you never see a complete path.
  • A flicker is just more stops. The db node lands with values="0;0.55;0.04;0.6;0.06;0.5;0.1;0.34" and a matching keyTimes, then freezes on the last value. Uneven gaps are what make it read as electrical rather than as a fade — evenly spaced stops look like a throb. No extra elements, no JavaScript, one attribute.
  • Colour per node is a plain fill on each glow rect with the opacity animated on top, so the hue is fixed and only its strength moves. These are literal hexes rather than currentColor because each one means a specific node; at 26% they sit under the label in both themes. Anything that should follow the theme still wants currentColor.
  • Resetting on loop. A frozen value stays frozen forever, so each node’s glow carries a second <animate> that snaps it back to 0 on begin="s1.begin" — the moment the sequence restarts. Freeze holds the state; the restart event clears it. Without that pair, the diagram lights up once and never goes dark again.
  • begin="0s;s4.end+1s" — two start conditions: once immediately, then again a second after the last step. That is how you loop a sequence, which repeatCount alone cannot do.

Two traps that cost me an hour#

Both fail silently — no console error, no build warning, just an animation that never runs.

Hyphens in an id break begin. This is the one that hurts:

<animate id="step-1"begin="0s"/>
<animate id="step-2"begin="step-1.end"/>   <!-- never fires -->

<animate id="step1"begin="0s"/>
<animate id="step2"begin="step1.end"/>    <!-- works -->

begin splits the value on . to separate the id from the event, and a hyphenated id does not survive that in Chrome. The element is found by getElementById, the attribute reads back correctly in DevTools, and the first animation fires its endEvent — the listener on the second one is simply never wired. Use camelCase ids for anything referenced by begin. CSS class names are unaffected; this is only about ids in SMIL timing.

Text on its own line inside an SVG element becomes a <p>. In MDX, this:

<text x="500" y="112">
  attempt 1 — timeout
  <animate … />
</text>

compiles to <text><p>attempt 1 — timeout</p><animate …/></text>, because MDX reads that line as a Markdown paragraph. And <p> is one of the tags that forces the HTML parser out of SVG foreign content — so everything after it in the diagram is dropped. The page looks fine; half the figure is just missing. Keep the text on the same line as its tag:

<text x="500" y="112">attempt 1 — timeout<animate … /></text>

06 · Easing#

Linear motion looks mechanical. calcMode="spline" plus keySplines gives you cubic-bezier easing, the same four numbers CSS uses.

lineareased
Same distance, same duration. Top is linear; bottom eases in and out, and reads as a physical thing rather than a scrubbed slider.

keyTimes says when each value in values is reached, as fractions of the duration. keySplines gives one easing curve per gap between values — so three values need two splines. Mismatch the counts and the animation silently does nothing, which is the single most common way this goes wrong.


07 · CSS keyframes and staggering#

For repeating decoration, CSS is shorter than SMIL — and animation-delay gives you staggering, which SMIL makes tedious.

One class, twelve bars, delay computed per bar with nth-child. Equalizers, loading states, queue depth — all the same trick.

Note the @media (prefers-reduced-motion: reduce) block at the bottom of that style. It changes nothing for almost everyone — it only matches when a reader has switched on Reduce motion in their OS accessibility settings, which people who get motion sickness or vertigo from moving interfaces do. For them a looping animation is not a flourish, it is a reason to close the tab. Cheap insurance at three lines, and invisible to everyone else.

That works here because this bar is animated by CSS keyframes, and animation: none genuinely turns a CSS animation off. The <animate> tags in every other figure on this page are a different technology with a different answer, and the obvious-looking version of it is a trap:

/* Looks right. Does nothing whatsoever. */
@media (prefers-reduced-motion: reduce) {
  svg animate, svg animateMotion { display: none }
}

display is not defined to have any effect on SMIL animation elements. I only found this by measuring: two identical rects, one under that rule and one with no rule at all, animated to exactly the same end position. Every figure I had “protected” this way was showing full motion to the readers who had asked for none.

The off switch that does work is to delete the nodes:

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  document
    .querySelectorAll("svg animate, svg animateMotion, svg animateTransform, svg set")
    .forEach((node) => node.remove());
}
  • Removing an animation element stops it — there is no ambiguity, unlike styling it.
  • Every animated attribute then falls back to whatever the markup says, which is exactly why rule one is draw the finished frame first. That frame is what a reduced-motion reader gets.
  • One catch: animateMotion post-multiplies onto an element’s own transform instead of replacing it. So a resting transform written directly on a moving group displaces the entire flight path. Declare it in a data attribute and apply it after the removal instead.

This site runs that snippet once from its base layout, so no figure has to remember.


08 · A diagram that explains itself#

Everything above, pointed at something real: a charge failing twice through a RetryingGateway before it succeeds. Static, this is a sequence diagram you have to read. Moving, you watch it happen.

callerRetryingGatewayproviderattempt 1 — timeoutattempt 2 — timeoutattempt 3 — captured
Two failures, then a success. The attempt counter and the red flashes are the same sequencing trick from section 05 — each step begins when the previous one ends.

Nothing in that figure is a video. It is about two kilobytes of text, it stays sharp at any zoom, it follows the page’s light and dark themes because every stroke is currentColor, and its aria-label describes the whole sequence for a reader who never sees it move.


09 · Path morphing#

d is just another attribute, so you can animate the shape itself — as long as every value has the same number of points in the same order. Different point counts and nothing happens.

One path, three shapes. Same command sequence throughout — only the coordinates change.

10 · Recipes for systems diagrams#

The four shapes I keep needing. Each is a starting point to paste and edit, not a finished picture.

Data flowing along edges#

The core move for any topology diagram: a dot per edge, each on its own clock so the diagram never pulses in lockstep. begin with a plain offset staggers them.

gatewaysvc-asvc-bsvc-c
One dot per edge, staggered with begin='0s', '0.6s', '1.2s'. Nothing is chained here — independent loops read as continuous traffic rather than a sequence.

To make one edge look busier than another, give it a shorter dur — or add a second dot on the same path with a half-cycle offset.

Heartbeat and ping#

An expanding ring that fades. r grows while opacity falls, both on the same duration, and the node itself pulses underneath.

leaderfollower
Two animations on one circle — radius out, opacity down. The follower answers half a beat later using begin='0.9s'.

A node failing, and traffic rerouting#

Sequencing again, with the state change carried by fill="freeze" so the failed node stays failed rather than flickering back.

clientreplica-1replica-2
Traffic to the middle node, then failure, then the same traffic taking the lower path. freeze holds the failed state until the loop restarts.

Replication fan-out and lag#

The same write leaving a leader three times, each arriving later. Different dur on identical paths is replication lag, drawn.

leaderfollower 1follower 2follower 3
Three identical writes, three different durations — 0.9s, 1.5s and 2.4s. The spread is the lag.

11 · Four things you will need and won’t guess#

Pausing mid-path#

A message that travels, stops at a hop, then carries on. animateMotion normally covers its path at a constant rate; keyPoints says how far along to be at each keyTime, and repeating a value holds position.

producerbrokerconsumer
keyPoints repeats 0.45 and 0.45 — the dot reaches the middle node, waits while it does its work, then continues. calcMode='linear' is required for keyPoints to apply.

keyPoints are fractions of the path; keyTimes are fractions of the duration. Same count in both, both ending at 1. That pairing is how you make one dot cover several hops at different speeds.

Stepped changes with calcMode="discrete"#

Interpolation is wrong for state. A node is FOLLOWER or CANDIDATE, never 40% of the way between them. discrete snaps instead of blending.

FOLLOWERCANDIDATELEADER
Three labels sharing one spot, each with its own visibility window on a shared 6s clock. discrete means they cut rather than cross-fade.

Note the shape: every label runs the same 6-second clock, and they differ only in which slot of values holds the 1. Adding a fourth state means adding a column to each row, not re-timing anything.

You cannot animate text content#

There is no attributeName="textContent". A counter cannot tick 1 → 2 → 3 from SMIL — the number is character data, not an attribute.

The workaround is the figure above: stack every string you need as its own <text>, and animate visibility. Fine for a handful of states, unworkable for a live counter. A counter is where a React component starts earning its keep.

<set> for a plain state flip#

When something just changes and stays changed, <set> says so without pretending to be an animation.

<!-- what I wrote first — a 10ms animation, which is a lie about intent -->
<animate attributeName="opacity" to="0" dur="0.01s" begin="s1.begin" fill="freeze"/>

<!-- what it means -->
<set attributeName="opacity" to="0" begin="s1.begin"/>

<set> takes no values, no dur, no interpolation — one attribute, one new value, at one moment. Use it for the reset half of a freeze pair, for marking a node dead, for switching a stroke to a dashed partition line.


12 · Reference#

The attributes worth memorising#

AttributeWhat it does
attributeNameWhich attribute to animate. Camel case matters.
valuesSemicolon-separated stops. Repeat a value to hold on it.
from / toTwo-stop shorthand for values.
durTime for one pass through values.
begin0s, 1.5s, otherId.end, otherId.end+1s, elementId.click, or a semicolon list of any of them.
repeatCountA number, or indefinite. Repeats one animation — not a chain.
fillfreeze holds the last value; remove (default) snaps back.
calcModespline to ease (needs keyTimes and keySplines); discrete to snap between states.
keyTimesWhen each value lands, as fractions of dur. Same count as values.
keySplinesOne cubic-bezier per gap, so one fewer than values.
additivesum composes with other transforms instead of replacing them.
keyPointsWith animateMotion — how far along the path at each keyTime. Repeat a value to pause.

Choosing the tag#

TagFor
<animate>Any single attribute — cx, r, opacity, stroke-dashoffset, d.
<animateMotion>Moving along a path. Add rotate="auto" to face the direction of travel.
<animateTransform>rotate, scale, translate, skew. Rotation takes its centre inline.
<set>A one-off state flip — no interpolation, no duration.
<style> + @keyframesRepeating decoration, and anything needing nth-child staggering.

Building one from scratch#

  1. Draw it static first, and make it good. The still frame is what a screenshot, a print, and a reduced-motion reader get. If it only works moving, it is the wrong diagram.
  2. Give every path you want to animate along an explicit d, and reuse that exact string in animateMotion. Copy-paste it — a path that drifts from its arrow is invisible in review and obvious on the page.
  3. camelCase every id you will reference from begin. See the hyphen trap above.
  4. Chain with begin="prev.end" rather than hand-computed offsets. Then a duration change anywhere re-times the rest for free.
  5. Add fill="freeze" to anything that should stay put once its step is done.
  6. Loop a sequence by giving the first animation two start conditions: begin="0s;lastId.end+1s".
  7. Use currentColor for every stroke and fill that should follow the page theme. Reserve a literal hex for something that means one specific thing in both themes, like an error red.
  8. Write the aria-label as the narration — what happens, in order. It is the only version a screen-reader user gets.
  9. Add the reduced-motion block to anything that loops. Only affects readers who asked for it.
  10. Verify it actually moves in a browser. Attribute values look right in the source and still do nothing — both traps above fail silently.

When not to animate#

Everything above is a reason to be careful, not a licence.

  • If the reader needs to study it, don’t move it. A class diagram is for reading at your own pace. Motion forces the reader onto your clock and makes them wait for the loop to come round.
  • Loops in the corner of the eye are a tax. Anything animating forever while someone is trying to read the paragraph next to it is competing with the words.
  • Motion is not the explanation. If the diagram only makes sense while moving, the still frame is wrong — and the still frame is what a screenshot, a print, and a reduced-motion reader all get.
  • Ship prefers-reduced-motion on anything that loops — and check that what you shipped works. CSS keyframes take animation: none; SMIL takes node removal and nothing else. It costs the animation nothing either way, and it means the few people who need stillness get a readable page instead of a moving one.

The good case is narrow and worth it: something that is a sequence in time — a retry, a request path, a queue draining, a cache filling. For those, a few hundred bytes of SVG beats a screen recording on every axis that matters.

Comments

Signed in with GitHub. Be kind.