Animating SVG with CSS

Part of Core CSS Animation Fundamentals.

Icons, logos, charts and illustrations are usually SVG, and a large share of interface motion — a checkmark drawing itself, a hamburger turning into a close icon, a spinner, a chart bar growing — happens inside one. CSS can animate SVG with the same @keyframes and transitions used everywhere else, but the rules underneath are different enough to cause a predictable set of bugs: shapes that rotate around the corner of the canvas, strokes that draw at the wrong speed, morphs that snap, and illustrations that repaint a large area on every frame.

This topic covers what changes when the animated element is an SVG shape rather than an HTML box: how the transform reference box works, which SVG properties are styleable and what they cost, how stroke drawing and path morphing interpolate, and how to keep SVG motion inside the frame budget. It builds on hardware-accelerated properties, because the compositor-only rule still applies — it just maps onto SVG less neatly.

Concept definition

CSS animation of SVG means applying transitions and keyframe animations to elements inside an inline svg: shapes such as <path>, <circle> and <rect>, groups (<g>), and the outer svg element itself. Most SVG presentation attributes — fill, stroke, stroke-width, stroke-dashoffset, opacity — are also CSS properties, and some geometry attributes are too: cx, cy, r, rx, ry, x, y, width, height and the path data property d in engines that support it.

The problem it solves is expressive, declarative motion for vector graphics without a JavaScript animation library or SMIL. The problem it introduces is that SVG shapes are not CSS boxes: they have no border box, their coordinate system is the SVG’s user space, and most of their properties affect paint rather than composite.

Where an animated SVG shape sits in the rendering stackAnimating the outer svg can use its own layer; animating a shape inside it usually repaints the svg's layer.Where an animated SVG shape sits in the rendering stackOuter svg elementAn HTML-level box: transform and opacity cancompositeGroup (g)Transform applies to children; paints into thesvgShape (path, circle)fill, stroke, d, r: paint every framePage layerUnaffected if the svg is promoted
Animating the outer svg can use its own layer; animating a shape inside it usually repaints the svg's layer.

Execution model

When a CSS animation targets a shape inside an SVG, the browser samples the animated value, updates the shape’s computed style, and — for almost every property — invalidates paint for the region the shape covers. The SVG’s contents are then re-rasterised into whatever layer the SVG is painted into. That is the same cost structure as animating background-color on an HTML element: main-thread style, then paint, then composite.

Transform and opacity inside SVG are the important exception, but a partial one. Engines can composite transform and opacity animations on SVG child elements in some circumstances, but support is narrower and less predictable than for HTML elements. The reliable way to get compositor-only motion is to animate an element that behaves like an HTML box: the outer svg element, or an HTML wrapper around it. For motion inside the graphic, animate transform and opacity on groups rather than geometry attributes, and treat the result as a paint animation bounded by the SVG’s size.

The reference box is the most common source of confusion. For HTML elements, transform-origin: center means the centre of the element’s border box. SVG shapes have no border box, and the default transform-box for SVG elements is view-box — the SVG’s viewport — so transform-origin: 50% 50% refers to the centre of the whole canvas, and a default origin of 0 0 is its top-left corner. Setting transform-box: fill-box makes percentages and keywords refer to the shape’s own bounding box, which is what almost every icon animation wants. The full set of cases is in transform-origin and transform-box in SVG.

Rasterisation scale matters for SVG in a way it does not for most HTML. A vector graphic promoted to its own layer is rasterised at the size it had when promoted. Scaling that layer up with a compositor animation stretches the texture, and the graphic looks soft until the animation ends and it re-rasterises. For SVG that scales significantly, either animate from a larger size down, or accept a repaint at the end.

Rotating a gear icon: canvas origin against shape originThe keyframes are identical; only the reference box differs.Rotating a gear icon: canvas origin against shape originDefault (view-box)transform-origin: center of the whole svgGear orbits the canvas centreOff-centre shapes swing wildlyRotates around the wrong pointtransform-box: fill-boxtransform-origin: centre of the gear's bboxGear spins in placeWorks for any position in the canvasRotates as designed
The keyframes are identical; only the reference box differs.

Property / API reference table

Property Accepted values Compositing tier Notes
transform on outer svg any transform composite Behaves like an HTML element; the reliable fast path
transform on <g> or shape any transform paint (engine-dependent) Set transform-box: fill-box for local origins
transform-box view-box, fill-box, stroke-box, content-box, border-box style SVG default is view-box
opacity / fill-opacity / stroke-opacity 0 to 1 paint (composite on outer svg) opacity on a group fades it as one flattened unit
fill, stroke colours, url() paint servers paint Colour interpolation is cheap per frame but repaints the shape
stroke-dasharray, stroke-dashoffset lengths, percentages, numbers paint Line-drawing effects; normalise with pathLength
stroke-width length paint Changes geometry bounds; can enlarge the repaint region
d path("..."), none paint Interpolates only between structurally compatible paths
cx, cy, r, rx, ry lengths paint Geometry as CSS properties; not supported everywhere
offset-path on SVG shapes path(), shapes paint Motion along a path; see motion path

Annotated code examples

1 — A spinner that stays on the compositor

/* Intent: an indeterminate spinner that keeps turning while the main thread is busy. */
.spinner {
  inline-size: 2.5rem;
  block-size: 2.5rem;
  animation: spin 900ms linear infinite;   /* transform on the outer svg element: compositor-driven */
}

.spinner circle {
  fill: none;
  stroke: currentColor;
  stroke-width: 4;
  stroke-dasharray: 0.7 0.3;               /* static dash pattern: a 70% arc */
}

@keyframes spin {
  to { rotate: 1turn; }
}

@media (prefers-reduced-motion: reduce) {
  .spinner { animation: pulse 2s ease-in-out infinite; }  /* no rotation: a slow opacity pulse */
  @keyframes pulse { 50% { opacity: 0.5; } }
}
<svg class="spinner" viewBox="0 0 50 50" role="img" aria-label="Loading">
  <circle cx="25" cy="25" r="20" pathLength="1" />
</svg>

Rendering Impact: composite. The rotation is on the outer svg, which is promoted like any HTML element; the arc is painted once and never repainted during the animation.

The tempting alternative — animating stroke-dashoffset on the circle to make the arc chase itself — looks richer but repaints the whole spinner every frame and freezes during long tasks, which is exactly when a spinner is on screen. Keep the geometry static and rotate the container.

2 — Morphing a menu icon into a close icon

/* Intent: three bars become an X when the menu button is pressed. */
.menu-icon path {
  transition: d 220ms cubic-bezier(0.3, 0.7, 0.2, 1), opacity 120ms linear;
}

.menu-icon .top    { d: path("M4 7 L20 7"); }
.menu-icon .middle { d: path("M4 12 L20 12"); }
.menu-icon .bottom { d: path("M4 17 L20 17"); }

[aria-expanded="true"] .menu-icon .top    { d: path("M6 6 L18 18"); }
[aria-expanded="true"] .menu-icon .middle { opacity: 0; }
[aria-expanded="true"] .menu-icon .bottom { d: path("M6 18 L18 6"); }

@media (prefers-reduced-motion: reduce) {
  .menu-icon path { transition: none; }
}

Rendering Impact: paint, bounded by a 24px icon. Every frame repaints the icon’s area, which is a trivial region; the cost is acceptable because it is small, not because it is free.

Each pair of d values has the same structure — a move command and a line command — so the browser can interpolate point by point. Where d is not supported as a CSS property, the declarations are ignored and the d attributes in the markup apply; the icon still changes state, just without the morph. The rules for compatible paths are in morphing SVG paths with the d property.

3 — A checkmark that draws itself once

/* Intent: a success checkmark that strokes on when the form submits. */
.check path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;                          /* fully hidden: dash offset equals its length */
  transition: stroke-dashoffset 360ms cubic-bezier(0.65, 0, 0.35, 1);
}

.is-success .check path {
  stroke-dashoffset: 0;                          /* fully drawn */
}

@media (prefers-reduced-motion: reduce) {
  .check path { transition: none; }              /* appears drawn immediately */
}
<svg class="check" viewBox="0 0 24 24" aria-hidden="true">
  <path d="M5 12.5 L10 17 L19 7" pathLength="1" fill="none" stroke="currentColor" stroke-width="2.5" />
</svg>

Rendering Impact: paint, small region, one-shot. pathLength="1" makes dash values a fraction of the path, so the CSS works for any path without measuring it.

Paint cost per frame for common SVG effectsMid-range phone. A full-width illustration repainting every frame is the case that breaks the budget.Paint cost per frame for common SVG effectsbudget 16.7 msRotate outer svg (spinner)0.1 msMorph d on a 24px icon0.4 msStroke draw on a 24px icon0.3 msStroke draw on a 600px illustration5.8 msAnimated gradient fill, full-width hero19.6 ms
Mid-range phone. A full-width illustration repainting every frame is the case that breaks the budget.

DevTools workflow

  1. Check the reference box first. Select the animated shape in the Elements panel and look at the Computed tab for transform-box and transform-origin. If the box is view-box and the shape rotates oddly, that is the bug.
  2. Scrub the animation. Open the Animations drawer, trigger the effect and drag the scrubber. A d morph that jumps from one shape to the other at 50% is not interpolating: the paths are structurally incompatible.
  3. Measure paint area. Enable Rendering → Paint flashing. An icon-sized green flash per frame is fine; a flash covering a whole illustration means the animated property forces a repaint of everything painted into the same layer.
  4. Confirm what is composited. Open the Layers panel and select the SVG. If you expect a compositor animation, the svg should have its own layer with a compositing reason such as an active transform animation.
  5. Trace a long illustration animation. Record in the Performance panel and look for Paint events every frame with large dimensions. If paint dominates, split the graphic so the animated part is its own svg that can be promoted.

Failure modes & fixes

  • An icon rotates around the corner of the canvas. Root cause: SVG elements default to transform-box: view-box, so the origin is in canvas coordinates. Fix: set transform-box: fill-box and transform-origin: center on the animated shape or group.
  • A line-draw animation runs at different speeds on different paths. Root cause: dash values are in user units, so longer paths need larger values and the same duration covers more distance. Fix: add pathLength="1" to each path and animate stroke-dashoffset from 1 to 0.
  • A path morph snaps halfway. Root cause: the two d values have different numbers or types of commands. Fix: redraw both shapes with matching command sequences, usually by converting lines and arcs to cubic curves.
  • Animation defined in the page stylesheet does nothing. Root cause: the SVG is loaded with <img> or as a CSS background, where page styles cannot reach it. Fix: inline the SVG, or put the animation and its reduced-motion query inside the SVG file.
  • A large illustration drops frames on phones. Root cause: per-frame paint of the whole graphic. Fix: separate the moving part into its own svg layered over a static one, and animate transform or opacity on that element.

Accessibility and reduced-motion notes

SVG animation is often decorative — a logo shimmer, an illustration that breathes — and decorative motion should be removed entirely under prefers-reduced-motion: reduce, following the prefers-reduced-motion architecture. Functional SVG motion, such as a spinner or a success checkmark, should keep its meaning in a still or gently fading form: a spinner can pulse its opacity, a checkmark can appear already drawn.

An SVG loaded as an <img> has its own document, and its internal @media (prefers-reduced-motion: reduce) query is evaluated against the user’s setting. That makes self-contained animated SVG files respect the preference — as long as the query is actually in the file. Audit exported animations from design tools, which rarely include it.

Label the meaning, not the motion. A loading spinner needs role="img" and an aria-label, or a visually hidden status message; the rotation carries no information for a screen reader. Purely decorative animated SVGs should be aria-hidden="true" so their changing internals are never announced.

Choosing what to animate in an SVGMove the animated part up the tree until it is either tiny or its own compositable element.Choosing what to animate in an SVGEffect neededWhole graphicmoves?animate the outersvgPart movesrigidly?transform on a gShape changes?d or stroke, keep itsmallLarge andrepainting?split into its ownsvg
Move the animated part up the tree until it is either tiny or its own compositable element.

Frequently asked questions

Why does transform-origin: center not work on my SVG path?

SVG elements use the SVG viewport as their reference box by default, so center means the centre of the whole canvas. Set transform-box: fill-box on the element and center will refer to the path’s own bounding box.

Are SVG animations hardware accelerated?

Transform and opacity animations on the outer svg element are handled like any HTML element and can run on the compositor. Animations on shapes inside the SVG, and animations of properties such as fill, stroke, d or stroke-dashoffset, generally repaint the SVG on each frame. Keep those effects small or isolate the animated part.

Should I use SMIL or CSS for SVG animation?

CSS, for anything that can be expressed in CSS. It shares the cascade, media queries and reduced-motion handling with the rest of the page, and it can be controlled with the Web Animations API. SMIL remains useful for a few attribute animations CSS cannot reach, such as animating along a path inside a standalone SVG file.

Can I animate an SVG used as an img or background-image?

Only from inside the SVG file itself. Page CSS cannot select elements inside an externally referenced SVG. Put the keyframes and a reduced-motion media query in a style element within the SVG, or inline it in the page.

Does the d property work in every browser?

No. It is supported as an animatable CSS property in Chromium-based browsers and Firefox but not everywhere, so treat path morphs as an enhancement and make sure the static d attributes represent a correct state on their own.