SVG has an animation system of its own. It was specified alongside SVG itself over twenty years ago, it needs no CSS and no JavaScript, and it still ships in every browser you care about.
Almost nobody uses it.
That's the odd thing about SVG animation. There are more ways to do it than there are for anything else on the web, and the one purpose-built for the job is the one you're least likely to reach for.
So let's go through all of them. SMIL, CSS, the Web Animations API, plain JavaScript, and Motion. What each one can reach, what it can't, what it costs to run, and which I'd actually pick.
SMIL: animation baked into the format
SMIL is the set of animation tags you nest inside SVG elements: <animate>, <animateTransform> and <animateMotion>. You describe the animation as markup, in the same file as the shape it animates.
<circle cx="50" cy="50" r="20">
<animate
attributeName="r"
values="20; 40; 20"
dur="2s"
repeatCount="indefinite"
/>
</circle>
That's a pulsing circle with no stylesheet and no script. attributeName points at an attribute on the parent element, values is the keyframe list, and dur and repeatCount do what you'd expect.
The thing to notice is attributeName. SMIL can animate any attribute. Not a curated list of animatable properties, not the ones a working group got round to promoting. Any of them. viewBox, points on a polyline, stdDeviation on a blur, baseFrequency on an <feTurbulence>. If it's an attribute, SMIL can animate it.
Nothing else on this list can say that.
Motion along a path
<animateMotion> is the genuinely special one. It moves an element along a path, and it can point at a path that already exists in the document.
<path id="track" d="M10,80 Q100,10 190,80" />
<circle r="6">
<animateMotion dur="3s" repeatCount="indefinite" rotate="auto">
<mpath href="#track" />
</animateMotion>
</circle>
<mpath> references #track by id, so the curve you can see and the curve the circle follows are the same data. Edit one and you've edited both. rotate="auto" turns the circle to face its direction of travel.
For the best part of two decades there was nothing else on the web that could do this. CSS has since caught up: offset-path and offset-distance do the same job and have been in every engine since Safari 16. But <animateMotion> got there first, and it still works.
The deprecation that wasn't
You'll read that SMIL is deprecated. It isn't, and the story is worth knowing.
Chrome 45 shipped an intent to deprecate SMIL back in 2015, pointing developers at CSS animations and the Web Animations API instead. Developers pointed back at path morphing and at animation inside <img>-referenced SVGs, neither of which had a replacement. Chrome suspended the deprecation in August 2016 and has shipped SMIL ever since.
So it isn't going anywhere. But "not deprecated" and "actively invested in" are different things, and it shows.
There are no springs. There's no interruption model worth the name. And driving it from JavaScript means beginElement(), endElement() and mutating attributes on the animation tag itself, which is about as pleasant as it sounds.
SMIL is lovely for a self-contained animated icon in a file you'll never touch again. It's the wrong tool for anything a user can interrupt.
CSS: most of SVG is already CSS
SVG's "presentation attributes" like fill, stroke, stroke-width and opacity are also CSS properties. Which means they transition and animate like anything else on the page:
circle {
fill: #0f0;
transition: fill 0.3s;
}
circle:hover {
fill: #f00;
}
SVG 2 went further and promoted a handful of geometry attributes to properties too: cx, cy, r, rx, ry, x, y, width and height. Those landed across browsers between 2020 and 2024, so a circle's radius is now something a stylesheet can animate directly.
That covers a surprising amount of ground. It's also where the surprises start.
The transform origin trap
Rotate an element in CSS and it spins around its own centre. Rotate an element in SVG and it spins around the origin of the viewBox, which is usually the top left corner of the entire graphic, and almost never what you wanted.
The fix is transform-box: fill-box, which tells the browser to resolve transform-origin against the element's own bounding box, the way CSS does:
.spinner {
transform-box: fill-box;
transform-origin: center;
animation: spin 1s linear infinite;
}
That's supported back to Safari 11, so there's no reason not to set it. If you use Motion you don't have to. svgEffect and the React motion components apply fill-box themselves the moment they see a transform, and you can opt back out with transformBox: "view-box".
Morphing with d
Path data is a CSS property too, in some browsers. Set d with path() and you can transition between two shapes with no JavaScript at all:
path {
d: path("M 0,0 L 0,10 L 10,10");
transition: d 0.4s;
}
path:hover {
d: path("M 0,0 L 10,0 L 10,10");
}
The catch is Safari, which doesn't support the d property at all. Chrome has had it since 52 and Firefox since 97, so this is a two-engines-out-of-three feature. Progressive enhancement only.
There's a second catch, and this one applies to every technique here rather than just CSS: browsers interpolate path data command by command. Both paths need the same number of commands, of the same types, in the same order. Try to morph a five-point star into a heart and you'll get garbage, or nothing.
What CSS can't reach
Everything that never became a property. viewBox. points on a <polyline>. x1/y1/x2/y2 on a <line>. offset on a gradient stop. Every filter primitive attribute, so stdDeviation, baseFrequency, numOctaves and the rest.
For those, CSS has nothing at all. Which leads us somewhere slightly awkward.
Can the Web Animations API help here?
Not with attributes, no.
The Web Animations API is a JavaScript interface onto the browser's own animation engine, the same engine that runs your CSS animations. That's its great strength: hand it a compositor-friendly property and the animation can run off the main thread entirely, staying smooth even when your JavaScript isn't.
But because it is the CSS engine, it only speaks CSS properties.
// cx became a CSS property in SVG 2, so this works
circle.animate({ cx: ["0px", "50px"] }, 1000)
// viewBox never did, so this does nothing at all
svg.animate({ viewBox: ["0 0 100 100", "0 0 50 50"] }, 1000)
Note the units on that first line. WAAPI wants a complete CSS value, so a bare 50 is an error where "50px" is fine. That's one of a pile of papercuts I've catalogued in Improvements to the Web Animations API.
The second line is the more interesting failure. It doesn't throw. It builds an animation over a property the engine has never heard of, plays it happily for a full second, and animates nothing.
So WAAPI reaches exactly the subset CSS reaches, no more. For everything else you're writing the animation yourself.
Plain JavaScript: the escape hatch
Every SVG attribute is reachable from setAttribute. So the universal fallback is to run your own loop and write the value each frame:
function draw(t) {
const x = 100 + Math.sin(t / 200) * 40
line.setAttribute("x2", x)
requestAnimationFrame(draw)
}
requestAnimationFrame(draw)
This works on everything. viewBox, points, filter primitives, the lot. It's also how every JavaScript animation library reaches SVG attributes underneath, GSAP and Motion included.
You do pay for it. The animation runs on the main thread, so it's vulnerable to jank whenever the main thread is busy. And changing an attribute changes the shape, so the browser redraws it every frame. We'll come back to what that costs.
The bigger cost is that you've written a loop, not an animation. No easing beyond what you code, no interruption, no springs, and cancellation is your problem. Multiply by every attribute you want to touch.
Line drawing, the hard way
Let's do the classic, because it's the single most requested SVG animation and it shows the gap between the platform and a library better than anything else.
There is no draw property. The self-drawing stroke effect is a trick played with two properties designed for dashed lines: stroke-dasharray and stroke-dashoffset.
stroke-dasharray takes a dash length and a gap length. Give it a dash exactly as long as the whole path, followed by a gap exactly as long as the whole path, and you have a fully drawn line trailed by a gap you'll never see.
stroke-dashoffset then slides that pattern along the stroke. Push the offset out by the full length and the dash slides off the end, leaving the invisible gap sitting over the path. The line has vanished.
Animate the offset back to zero and it draws itself.
The snag is "exactly as long as the whole path", because you have to go and measure it:
const path = document.querySelector("path")
const length = path.getTotalLength()
path.style.strokeDasharray = length
path.style.strokeDashoffset = length
path.animate({ strokeDashoffset: [length, 0] }, 2000)
That works. It's also fiddly in every direction at once.
getTotalLength() is a DOM measurement, so the animation can't live in a stylesheet. The number is different for every path, so nothing you write is reusable. Drawing half the line means length * 0.5, so you're doing arithmetic inside your animation code. Change the path and you re-measure. And if you set these values in px, Safari will scale them with the page zoom, which is a fun afternoon.
GSAP fixed the ergonomics of this years ago with DrawSVGPlugin, which hides all of the above behind a percentage. It's a genuinely nice API, and since Webflow made every GSAP plugin free in April 2025 it costs nothing to use.
But there's a much simpler answer sitting in the spec.
Enter pathLength
pathLength is an attribute that tells the browser to lie about how long a path is. Set it, and every stroke calculation gets scaled as though the path were that length instead of its real one.
So set it to 1:
<path
pathLength="1"
stroke-dasharray="1 1"
stroke-dashoffset="1"
/>
Now the dash maths is in progress units. An offset of 1 is fully hidden, 0 is fully drawn, 0.5 is half. No measuring, no magic numbers, and the same three lines work on every path in your document.
This is exactly what Motion does under the hood. It sets pathLength="1" on the element, writes stroke-dasharray and stroke-dashoffset as unitless numbers (which is also what sidesteps that Safari zoom bug), and hands you three normalised values you can animate like any other:
-
pathLength, how much of the line is drawn -
pathSpacing, the gap between drawn segments -
pathOffset, where along the path the drawn segment starts
Which makes the whole effect one line:
animate("path", { pathLength: [0, 1] }, { duration: 2 })
Or, in React:
<motion.path
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
/>
It works on circle, ellipse, line, path, polygon, polyline and rect, and there's a full path drawing example if you want to pull it apart.
Because these are ordinary values, you also get things that would be miserable by hand. Leave pathSpacing unset and Motion derives it for you, so looping the offset gives you an infinite chasing-line loader:
const pathLength = motionValue(0.25)
const pathOffset = motionValue(0)
svgEffect("path", { pathLength, pathOffset })
animate(pathOffset, [0, 1], { repeat: Infinity, ease: "linear" })
What Motion adds
I build Motion, so weigh the following accordingly. Here's what it does for SVG specifically that the platform doesn't.
Attributes and styles through one API. In React there's a motion component for every SVG element, and attributes animate exactly like styles:
<motion.circle cx={0} animate={{ cx: 50 }} />
<motion.svg> will animate viewBox, which is the tidiest way to pan or zoom a graphic and something nothing else on this list can express declaratively:
<motion.svg
viewBox="0 0 200 200"
animate={{ viewBox: "-100 -100 300 300" }}
/>
In vanilla JavaScript, animate covers styles and the path values, and svgEffect covers arbitrary attributes via motion values. The full picture is in the SVG animation docs.
Springs on SVG values. Neither CSS nor WAAPI has a spring. Motion will spring a path value the same as it springs anything else:
animate("path", { pathLength: 1 }, { type: spring, bounce: 0.4 })
Scroll-linking. Scroll timelines are still landing: Chromium has had them since 115 and Safari since 26, but Firefox support doesn't arrive until 155. Motion's scroll has driven the same animations in every browser for years, hardware accelerated wherever the browser allows it:
scroll(animate("path", { pathLength: [0, 1] }))
Morphing. Motion interpolates d natively, in every browser including Safari, as long as the two paths have matching commands. For genuinely different shapes you pair it with a path mixer like Flubber, which is what our SVG path morphing example does.
If you morph arbitrary shapes regularly, though, GSAP's MorphSVGPlugin solves the point-matching problem properly rather than asking you to bolt on a mixer. That's the strongest reason on this page to pick GSAP instead, and I'd rather say so than pretend otherwise.
What does an SVG animation cost?
Short version: the inside of an SVG is paint territory.
I went through the full render pipeline in the web animation performance tier list, but the SVG-specific version is quick. Change path data, or a circle's radius, or a stroke dash offset, and you've changed the shape. The browser has to redraw it. That's the paint step, every frame, on the main thread.
That isn't a reason to avoid it. Line drawing is a paint animation, there's no version of it that isn't, and a small icon repainting at 60fps is nothing at all.
What scales the cost is area. A signature tracing itself inside a 40px icon is free. The same code stroking a path across a full-viewport hero is not.
Transforms are the exception, sort of. A transform on the <svg> element itself behaves like a transform on any other element, so the browser can promote it to a layer and hand the animation to the compositor. A transform on a <path> inside the SVG generally doesn't get that treatment, and repaints along with everything else.
Hence the practical rule: to move or scale a graphic, transform the <svg>. To animate what's inside it, accept the paint and keep the painted area small.
The comparison
| SMIL | CSS | WAAPI | Motion | |
|---|---|---|---|---|
| Declarative | Yes | Yes | No | Yes, in React |
| Any attribute | Yes | No | No | Yes |
| Path morphing | Yes | Not in Safari | Not in Safari | Yes, everywhere |
| Motion along a path | <animateMotion> | offset-path | offsetDistance | offsetDistance and arc() |
| Springs | No | Generated linear() only | Generated linear() only | Yes |
| Scroll-linking | No | Scroll timelines | Scroll timelines | scroll(), with a fallback |
| Dynamic values | Awkward | Via classes and variables | Yes | Yes |
| Browser support | Every evergreen browser | Varies per property | Every evergreen browser | Every evergreen browser |
So which should you use?
If the animation is decorative, self-contained, and nothing will ever interrupt it, use CSS. It's the cheapest thing to ship and the easiest to delete. Spinners, hover states, looping icons.
If it needs an attribute CSS never got and it has to run without script, inside an <img> tag say, SMIL is your only option. It isn't going anywhere, and for a fire-and-forget loop it's genuinely fine.
Use the Web Animations API directly when you want a JavaScript handle on an animation CSS could already express, and you don't mind writing out the units.
Reach for a library the moment the animation becomes part of your interface. Interruption, springs, gestures, scroll, values that come from state rather than a stylesheet: that's the line. Below it the platform is good enough that a dependency is hard to justify. Above it, you're rebuilding an animation library badly.
Bias acknowledged, the specific reason I'd reach for Motion here is that SVG stops being a special case. pathLength is a value like any other, so it springs, staggers, links to scroll and interrupts halfway through, and the code that animates a path looks like the code that animates a div.
SVG never got one good animation system. It got four half-overlapping ones, and knowing where the seams are is most of the skill.
Now go and draw something.