← Back to Index

Lesson 11

CSS Animation of SVG

Lessons 9–10 covered SMIL - SVG's built-in animation system. Now we shift to CSS, which brings familiar tools (transition, @keyframes, animation) to SVG elements. CSS animation is GPU-accelerated, integrates with your existing stylesheets, and handles already-familiar properties. But SVG has quirks - especially around transform-origin - that trip up even experienced CSS developers.

1. What CSS Can (and Can't) Animate on SVG

Not every SVG attribute is a CSS property. The key distinction from Lesson 4: only presentation attributes that have CSS property equivalents can be transitioned or animated with CSS. Here's the practical breakdown:

CSS-animatableNot CSS-animatable (use SMIL or JS)
opacityx, y, width, height (on most elements)
transformviewBox
fill, stroked (path data) - except in Chrome/Edge
fill-opacity, stroke-opacitypoints (polygon/polyline)
stroke-widthr, cx, cy (geometry props - see note)
stroke-dasharraydx, dy (text positioning)
stroke-dashoffsettextLength
color, filter, clip-pathgradientTransform, patternTransform
Geometry properties: the evolving spec

SVG 2 promotes r, cx, cy, rx, ry, x, y, width, height to CSS properties (the "geometry properties"). Chrome and Firefox now support animating these via CSS, but Safari's support is incomplete. For cross-browser reliability today, stick to SMIL or JavaScript for geometry attributes. The future is CSS, but the present is messy.

The rule of thumb

If you can set it in a CSS stylesheet and it works, you can transition/animate it with CSS. If it only works as an XML attribute (like viewBox or d in Firefox/Safari), you need SMIL or JavaScript.

2. CSS Transitions on SVG

Transitions work on SVG elements exactly as they do on HTML elements. Apply transition in CSS, then change the property via :hover, a class toggle, or any state change:

<style>
  .hover-circle {
    fill: coral;
    stroke: #333;
    stroke-width: 2;
    transition: fill 0.4s ease, stroke-width 0.3s ease, r 0.3s ease;
  }
  .hover-circle:hover {
    fill: #61afef;
    stroke: coral;
    stroke-width: 8;
    r: 55;
  }
</style>

<svg viewBox="0 0 200 200">
  <circle class="hover-circle" cx="100" cy="100" r="40"/>
</svg>

Hover the circle - fill, stroke-width, and r all transition smoothly. The stroke goes from thin dark (#333) to thick coral (2 → 8) for a clear visual difference.

Transitioning stroke properties

Stroke properties are particularly useful for hover states on icons and UI elements:

<style>
  .icon-path {
    fill: none;
    stroke: var(--muted);
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition: stroke 0.3s, stroke-width 0.2s;
  }
  .icon-path:hover {
    stroke: coral;
    stroke-width: 3;
  }
</style>

<svg viewBox="0 0 24 24" width="48" height="48">
  <path class="icon-path"
        d="M12 2 L2 7 L12 12 L22 7 Z M2 17 L12 22 L22 17 M2 12 L12 17 L22 12"/>
</svg>

Hover each layer of the icon - stroke colour and width transition independently per path.

3. @keyframes Animation

For continuous or multi-step animations, @keyframes gives you full control over the timeline. The syntax is identical to HTML element animations:

<style>
  @keyframes pulse {
    0%, 100% { opacity: 1; transform: scale(1); }
    50%      { opacity: 0.6; transform: scale(1.3); }
  }
  .pulse-circle {
    animation: pulse 2s ease-in-out infinite;
    transform-origin: center;  /* Critical for SVG  -  see section 4 */
  }
</style>

<svg viewBox="0 0 200 200">
  <circle class="pulse-circle" cx="100" cy="100" r="30" fill="#61afef"/>
</svg>

Pulsing circle using @keyframes. Note transform-origin: center - without it, the scale would anchor to the SVG top-left corner.

Multi-step colour cycling

<style>
  @keyframes color-cycle {
    0%   { fill: coral; }
    25%  { fill: #e5c07b; }
    50%  { fill: #98c379; }
    75%  { fill: #61afef; }
    100% { fill: coral; }
  }
  .cycling-rect {
    animation: color-cycle 4s linear infinite;
  }
</style>

<svg viewBox="0 0 200 120">
  <rect class="cycling-rect" x="50" y="30" width="100" height="60" rx="8"/>
</svg>

Four-colour fill cycle. CSS interpolates between named colours, hex, rgb - even currentColor.

Spinning loader

<style>
  @keyframes spin {
    to { transform: rotate(360deg); }
  }
  .spinner {
    animation: spin 1s linear infinite;
    transform-origin: center;
    fill: none;
    stroke: #61afef;
    stroke-width: 4;
    stroke-linecap: round;
    stroke-dasharray: 90 150;
  }
</style>

<svg viewBox="0 0 50 50" width="80" height="80">
  <circle class="spinner" cx="25" cy="25" r="20"/>
</svg>

Classic spinner: a dashed-stroke circle rotating continuously. Two lines of CSS.

4. The transform-origin Gotcha

This is the single biggest source of confusion when animating SVG with CSS. On HTML elements, transform-origin defaults to the element's centre (50% 50%). On SVG elements, the default is different depending on the SVG spec version and browser:

ContextDefault transform-origin
HTML elements50% 50% of the element's border box (its own centre)
SVG elements (SVG 1.1 / older browsers)0 0 of the SVG viewport (top-left of the entire SVG)
SVG elements (SVG 2 / modern browsers)0 0 of the element's own bounding box
Neither default is "element centre"

Unlike HTML, SVG elements do NOT default to rotating/scaling from their own visual centre. In modern browsers (SVG 2), the origin is the top-left corner of the element's bounding box. In older browsers, it's the top-left of the entire SVG canvas. Either way, you must explicitly set transform-origin for centre-based transforms.

The problem in action

<style>
  @keyframes spin {
    to { transform: rotate(360deg); }
  }
  /* Without transform-origin set */
  .bad-rotate {
    animation: spin 3s linear infinite;
    /* transform-origin defaults to bounding-box top-left! */
  }
  /* With transform-origin fixed */
  .good-rotate {
    animation: spin 3s linear infinite;
    transform-origin: center;  /* = 50% 50% of bounding box */
  }
</style>

<svg viewBox="0 0 400 200">
  <rect class="bad-rotate" x="75" y="75" width="50" height="50" fill="coral"/>
  <rect class="good-rotate" x="275" y="75" width="50" height="50" fill="#98c379"/>
</svg>
No transform-origin transform-origin: center

Left: rotates around its top-left corner (wrong). Right: rotates around its own centre (correct). Same @keyframes, different transform-origin.

The solutions

Three approaches to get centre-based transforms working reliably:

ApproachCodeBest for
Keyword center transform-origin: center Modern browsers (SVG 2). Simplest.
Percentage transform-origin: 50% 50% Same as above - resolves to bounding-box centre
Explicit coordinates transform-origin: 100px 100px When you need a specific fixed point (not the element's centre)
Always set transform-origin explicitly on animated SVG elements

Make it a habit: any time you use transform in a CSS animation or transition on an SVG element, add transform-origin: center (or the appropriate value). This eliminates the #1 debugging headache in SVG + CSS animation. The center keyword maps to 50% 50% of the element's bounding box in SVG 2, which is almost always what you want.

transform-box: another piece of the puzzle

When you write transform-origin: 50% 50%, the browser needs to know: 50% of what? The transform-box property answers that question. It defines the reference box that percentages resolve against:

transform-box: view-box viewport (200×170) 50% 50% = viewport centre transform-box: fill-box bounding box 50% 50% = element centre

Same transform-origin: 50% 50%, totally different results. Left: 50% resolves against the entire SVG viewport (element orbits a distant point). Right: 50% resolves against the element's own bounding box (element spins in place). The crosshair marks where the origin lands.

ValueWhat "50% 50%" meansWhen you'd see it
view-boxCentre of the nearest SVG viewport (the whole <svg> canvas)SVG 1.1 / legacy browsers. Elements orbit the SVG centre.
fill-boxCentre of the element's own bounding boxSVG 2 / modern browsers (default). Elements spin in place.
border-boxCentre of the element's CSS border boxHTML default. Rarely relevant for SVG elements.

Modern browsers default to fill-box for SVG elements, which is almost always what you want. But if you're seeing an element orbit a point far from itself (especially in older browsers or when the SVG has a large viewBox), the browser is using view-box instead. The fix: set both properties explicitly:

<style>
  @keyframes spin {
    to { transform: rotate(360deg); }
  }
  .my-svg-element {
    animation: spin 3s linear infinite;
    transform-box: fill-box;    /* "50% means 50% of MY box" */
    transform-origin: center;   /* = 50% 50% of that box */
  }
</style>

<svg viewBox="0 0 200 200">
  <rect class="my-svg-element" x="75" y="75" width="50" height="50" fill="coral"/>
</svg>
transform-box: view-box transform-box: fill-box Orbits the SVG centre Spins in place

Both have transform-origin: 50% 50%. Left uses view-box (50% = middle of viewport, so the rect orbits far from itself). Right uses fill-box (50% = middle of the rect's own box, so it spins neatly in place).

The two-property recipe

For bullet-proof, cross-browser SVG transforms: always set both transform-box: fill-box and transform-origin: center. This combination means "rotate around my own centre" regardless of browser age or SVG viewport size. Once this becomes muscle memory, you'll never debug a mysterious orbit again.

5. The Stroke Drawing Technique

This is the technique that makes SVG logos "draw themselves" on screen. It's one of the most visually satisfying effects in web animation, and it's built entirely on two CSS-animatable properties: stroke-dasharray and stroke-dashoffset.

How it works

The mental model:

  1. Set stroke-dasharray to the path's total length - this creates one dash as long as the entire path, followed by a gap of equal length
  2. Set stroke-dashoffset to the same value - this shifts the dash forward along the path so it sits entirely past the end, leaving only the invisible gap over the visible path length
  3. Animate stroke-dashoffset to 0 - the dash slides into view, creating the illusion of the path being drawn
<style>
  @keyframes draw {
    to { stroke-dashoffset: 0; }
  }
  .draw-path {
    fill: none;
    stroke: coral;
    stroke-width: 3;
    stroke-linecap: round;
    stroke-dasharray: 320;    /* total length of the path */
    stroke-dashoffset: 320;   /* initially hidden */
    animation: draw 2s ease forwards;
  }
</style>

<svg viewBox="0 0 200 200">
  <path class="draw-path"
        d="M 20,100 C 20,20 180,20 180,100 C 180,180 20,180 20,100"/>
</svg>

A figure-8 path drawing itself. Refresh the page (or click below) to replay.

Ad

Finding the path length

You need the path's total length so you know what value to set stroke-dasharray and stroke-dashoffset to. But why can't you just calculate it from the path data?

For straight lines it's easy (Pythagoras). But most paths contain Bézier curves, and Bézier curves have no closed-form formula for arc length. The length of a cubic Bézier can only be computed via numerical integration (the browser internally subdivides the curve into tiny segments and sums them). That's why getTotalLength() exists - it's the browser doing that calculation for you. Three practical approaches:

MethodHow
JavaScriptdocument.querySelector('path').getTotalLength()
Browser DevToolsSelect the path, run $0.getTotalLength() in Console
Over-estimateSet dasharray to a value larger than the path length - works perfectly, the excess is simply ignored
Over-estimating is perfectly fine

If your path is 287 units long and you set stroke-dasharray: 400; stroke-dashoffset: 400, the animation still works. Think of it like a train and a tunnel:

  • The tunnel is the path - only what's inside the tunnel is visible
  • The train is the dash - it can be longer than the tunnel
  • Dashoffset controls where the train is parked along the track

An over-long train fills the tunnel just as well as a perfectly-sized one - the extra carriages just stick out the far end where nobody can see them. The only subtlety: the animation spreads its duration across the full offset range, so the visible drawing finishes slightly before the animation clock runs out. With ease timing this trailing pause is imperceptible.

The train & tunnel: over-estimated dasharray TUNNEL (visible path: 200 units) start end train direction t = 0% offset: 300 Tunnel empty - train hasn't reached it yet train (waiting) t = 50% offset: 150 visible (path drawing) hidden t = 67% offset: 100 Fully drawn! Tunnel completely filled excess t = 100% offset: 0 Looks identical to t=67% - excess has passed through excess no visible change Drawing starts immediately. Finishes visually before the animation clock runs out.

The train (dash) travels left-to-right. At t=67% the tunnel is full - the last 33% of animation time is invisible. No start delay; slight end pause.

Segmented draw (simultaneous multi-point)

The standard technique animates stroke-dashoffset (sliding one long dash into view). This variant is a different mechanism entirely - it animates stroke-dasharray itself, growing multiple dashes from zero width simultaneously:

Standard drawSegmented draw
What's animatedstroke-dashoffsetstroke-dasharray
What's staticdasharray (one long dash + gap)dashoffset (stays at 0)
Mental modelSliding a fixed dash along the pathGrowing many dashes outward from their positions
Visual effectOne draw head, start → finishMultiple draw heads, all growing at once
<style>
  @keyframes segmented-draw {
    from { stroke-dasharray: 0 50; }  /* zero-width dashes, 50-unit gaps */
    to   { stroke-dasharray: 50 0; }  /* 50-unit dashes, zero gaps (solid) */
  }
  .segmented-path {
    fill: none;
    stroke: coral;
    stroke-width: 3;
    stroke-linecap: round;
    animation: segmented-draw 2s ease forwards;
  }
</style>

<svg viewBox="0 0 300 150">
  <path class="segmented-path"
        d="M 20,75 C 60,10 120,140 160,75 C 200,10 260,140 280,75"/>
</svg>

The path appears to grow from multiple points at once. Each dash expands from zero width to fill its segment, all simultaneously.

How it works: the dasharray pattern repeats along the path. Animating from 0 50 (invisible point-dashes with 50-unit gaps) to 50 0 (50-unit dashes with no gaps) makes each segment grow outward from its starting position. The number of visible "draw points" is the path length divided by your segment size (here, 50 units).

You can control the feel by adjusting the segment size:

Segment size and the pathLength trick

If your segment size doesn't divide evenly into the path length, the last segment will be shorter - which can look slightly uneven at the end. For circles and closed paths this is rarely noticeable. For open paths, there's a neat trick:

The pathLength attribute tells the browser: "for the purposes of dash calculations, treat this path as if it's this many units long." It doesn't change the shape or size of the path - it rescales the dash coordinate system. So a path that's actually 347.2 units long can declare pathLength="100", and now stroke-dasharray: 10 means "10% of the path" regardless of the real geometry.

<!-- pathLength="100" means dasharray/dashoffset work in percentages -->
<path pathLength="100"
      stroke-dasharray="0 10"
      d="M 20,80 C 60,10 140,10 180,80 S 300,150 340,80">
  <!-- Animating dasharray from "0 10" to "10 0" gives exactly 10 segments -->
</path>

<!-- Also works for the standard draw technique: -->
<path pathLength="1"
      stroke-dasharray="1"
      stroke-dashoffset="1">
  <!-- Animate stroke-dashoffset from 1 to 0. No getTotalLength() needed! -->
</path>
pathLength="1" is the ultimate shortcut

For the standard stroke-drawing technique, set pathLength="1" on any path, then use stroke-dasharray: 1; stroke-dashoffset: 1 and animate to 0. The browser maps "1" to mean "the full path" regardless of actual length. No JavaScript, no getTotalLength(), no guessing. Works on <path>, <circle>, <line>, <rect>, <polyline> - any shape with a stroke.

Multi-path staggered drawing

The real magic happens when you stagger multiple paths with animation-delay:

<style>
  @keyframes draw { to { stroke-dashoffset: 0; } }
  .letter {
    fill: none;
    stroke: coral;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-dasharray: 200;
    stroke-dashoffset: 200;
    animation: draw 1s ease forwards;
  }
  .letter:nth-child(2) { animation-delay: 0.3s; stroke: #e5c07b; }
  .letter:nth-child(3) { animation-delay: 0.6s; stroke: #98c379; }
  .letter:nth-child(4) { animation-delay: 0.9s; stroke: #61afef; }
</style>

<svg viewBox="0 0 320 100">
  <path class="letter" d="M 40,30 C 60,25 70,30 70,40 C 70,55 30,55 30,65 C 30,75 45,80 60,75"/>
  <path class="letter" d="M 90,25 L 110,80 L 130,25"/>
  <path class="letter" d="M 180,40 C 170,25 145,25 145,50 C 145,75 170,80 180,65 L 180,55 L 165,55"/>
  <path class="letter" d="M 210,25 L 210,60 M 210,72 L 210,75"/>
</svg>

Each letter draws itself with a 0.4s stagger. The delay creates a sequential "handwriting" feel.

Draw + fill reveal

A common pattern: draw the stroke first, then fade in the fill:

<style>
  @keyframes draw { to { stroke-dashoffset: 0; } }
  @keyframes fill-in { to { fill-opacity: 1; } }

  .draw-and-fill {
    fill: #61afef;
    fill-opacity: 0;
    stroke: #61afef;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 600;
    stroke-dashoffset: 600;
    animation:
      draw 2s ease forwards,
      fill-in 0.8s ease 1.8s forwards;
  }
</style>

<svg viewBox="0 0 200 200">
  <!-- Star shape -->
  <path class="draw-and-fill"
        d="M100,20 L120,75 L180,75 L130,110 L150,170
           L100,135 L50,170 L70,110 L20,75 L80,75 Z"/>
</svg>

Star draws its outline over 2s, then the fill fades in over 0.8s (starting at 1.8s via animation-delay in the shorthand).

Multiple animations on one element

Comma-separate multiple animation names in the animation shorthand to run them together (or staggered via delay). Each targets different properties, so they don't conflict. This is how you compose complex sequences - draw + fill, draw + colour change, draw + transform - without JavaScript.

6. Combining CSS Animation with SVG Structure

CSS animations become powerful when combined with SVG's structural features: <g> groups, <use> instances, and staggering via nth-child.

Animating groups

Apply an animation to a <g> and all its children move together - just like transforms on groups in Lesson 2:

<style>
  @keyframes float {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-10px); }
  }
  .floating-group {
    animation: float 3s ease-in-out infinite;
  }
</style>

<svg viewBox="0 0 200 200">
  <g class="floating-group">
    <rect x="70" y="70" width="60" height="60" fill="coral" rx="6"/>
    <circle cx="100" cy="60" r="8" fill="#e5c07b"/>
  </g>
</svg>

The group floats as a unit. Both the rectangle and circle share the same vertical oscillation.

Staggered child animations with nth-child

<style>
  @keyframes pop-in {
    from { transform: scaleY(0); opacity: 0; }
    to   { transform: scaleY(1); opacity: 1; }
  }
  .bar-chart rect {
    transform-origin: bottom;
    transform-box: fill-box;
    animation: pop-in 0.4s ease-out backwards;
  }
  .bar-chart rect:nth-child(1) { animation-delay: 0s; }
  .bar-chart rect:nth-child(2) { animation-delay: 0.1s; }
  .bar-chart rect:nth-child(3) { animation-delay: 0.2s; }
  .bar-chart rect:nth-child(4) { animation-delay: 0.3s; }
  .bar-chart rect:nth-child(5) { animation-delay: 0.4s; }
</style>

<svg viewBox="0 0 300 150">
  <g class="bar-chart">
    <rect x="30" y="50" width="40" height="90" rx="3" fill="coral"/>
    <rect x="85" y="30" width="40" height="110" rx="3" fill="#e5c07b"/>
    <rect x="140" y="60" width="40" height="80" rx="3" fill="#98c379"/>
    <rect x="195" y="20" width="40" height="120" rx="3" fill="#61afef"/>
    <rect x="250" y="70" width="40" height="70" rx="3" fill="#c678dd"/>
  </g>
</svg>

Bars pop in from the bottom with staggered delays. transform-origin: bottom + transform-box: fill-box ensures scaleY grows upward from the bar's base.

animation-fill-mode: backwards

When using animation-delay, the element is visible in its final state before the animation starts (which looks broken - bars appear then vanish then pop in). Setting backwards (or using the shorthand keyword) applies the from keyframe during the delay period, keeping elements invisible until their animation starts.

7. CSS vs SMIL: When to Use Which

Now that you know both systems, here's the practical decision framework:

Use CSS when…Use SMIL when…
Animating CSS-compatible properties (opacity, transform, fill, stroke-*)Animating non-CSS attributes (viewBox, d, points, r in older browsers)
You want GPU acceleration (transform, opacity)You need motion along a path (<animateMotion>)
The SVG is inline in HTML and you're already writing CSSThe SVG is standalone (loaded via <img> or <object>)
You need hover/focus states with transitionsYou need event-based timing (click to trigger a sequence)
You want to leverage CSS custom properties and media queriesYou need the rotation centre to change mid-animation
Performance is critical (transform + opacity are composited)You need additive animation (building on base values)
They're not mutually exclusive

You can mix CSS and SMIL on the same SVG - even the same element. Use CSS for what it's good at (transforms, opacity, hover states) and SMIL for what CSS can't do (path morphing, motion paths, attribute animation). The browser resolves both correctly. Just don't animate the same property with both systems simultaneously - that's a conflict the spec doesn't resolve predictably.

SMIL deprecation: the full story

Chrome briefly deprecated SMIL in 2015, but reversed that decision due to developer pushback. As of 2024, SMIL is fully supported in all browsers (Chrome, Firefox, Safari, Edge) and there are no active deprecation plans. The W3C considers it "in maintenance" (no new features), but existing features are stable. Use SMIL with confidence where it's the right tool.

Quiz

Question 1

What is the default transform-origin for SVG elements in modern browsers (SVG 2)?

50% 50% of the element's bounding box (its own centre)
0 0 of the element's bounding box (its top-left corner)
0 0 of the SVG viewport (the canvas top-left corner)
The element's cx/cy attributes if they exist

Question 2

In the stroke drawing technique, what do you animate to make a path appear to draw itself?

stroke-dasharray from 0 to the path length
stroke-dashoffset from the path length to 0
stroke-width from 0 to the desired width
path length from 0 to 1 using pathLength

Question 3

What does animation-fill-mode: backwards do when combined with animation-delay?

Plays the animation in reverse after it completes
Holds the final keyframe state after the animation ends
Applies the first keyframe styles during the delay period
Resets the element to its original styles after the delay

Question 4

Which of these SVG attributes can NOT be animated with CSS transitions (as of current browser support)?

fill (colour of the shape)
stroke-dashoffset (dash position)
viewBox (the coordinate window)
opacity (transparency of the element)

Question 5

What does transform-box: fill-box do on an SVG element?

Sets the fill colour to match the element's box model
Makes percentage transform-origin resolve against the element's own bounding box
Clips the transform to stay within the element's bounds
Forces the element to use border-box sizing for layout

Exercises

  1. Logo draw-on - Find or create a simple logo made of 3–5 paths. Use the stroke drawing technique with staggered animation-delay to make each path draw sequentially. Add a fill reveal at the end. Use getTotalLength() to get exact lengths.
  2. Transform-origin debugging - Create a grid of 9 squares (3×3). Apply a rotation animation to all of them but with different transform-origin values: top-left, center, bottom-right, specific pixel coordinates. Observe how each one rotates differently despite identical @keyframes.
  3. Hover icon set - Build 4 SVG icons (use simple geometric shapes). Give each a different hover transition: one scales up, one changes stroke colour, one increases stroke-width, and one rotates 15 degrees. All should use transition (not @keyframes).
  4. Loading spinner variants - Create three different CSS-animated spinners using only circles: (a) rotating dashed circle, (b) pulsing opacity ring, (c) two counter-rotating arcs (use stroke-dasharray to make partial arcs, animate rotation in opposite directions).
  5. Bar chart entrance - Create a 7-bar chart where bars grow from the bottom with staggered delays. Use scaleY with transform-origin: bottom and transform-box: fill-box. Add a second animation that fades in data labels above each bar after all bars have appeared.