Skip to content

SVG Animation

A UI principle for coding agents. Also covers svg, animated icons, svg paths, stroke-dasharray, stroke-dashoffset, self-drawing, and 14 more.

Show all 20 aliases

svg, animated icons, svg paths, stroke-dasharray, stroke-dashoffset, self-drawing, viewBox, svg masks, mask cutout, vector graphics, animated illustration, path animation, path morphing, d path() css, progress ring, transform-box fill-box, transform-origin svg, non-scaling-stroke, preserveAspectRatio, connector line between elements

Core Philosophy

  • SVG is the right medium when the shape itself has to change, not just its position or opacity
  • A <div> can move, fade and scale, but it cannot change its own outline. SVG keeps every shape as an addressable DOM node whose geometry you can rewrite, and it stays crisp at any size
  • Reach for SVG for icons that morph, illustrations that react, progress that traces a shape, and any bespoke mark that must stay crisp at every size
  • Stay in the DOM (HTML plus CSS) when the effect is transform-only. SVG earns its complexity when you need paths, strokes or masks
  • This doc owns motion: viewBox, stretching, self-drawing, morphing, masks that animate, transform origin. svg-drawing-fundamentals owns the static shapes, paint model and path syntax you animate. svg-visual-effects owns filters, gradients, patterns and curved text

Choosing SVG, HTML or Canvas

  • HTML plus CSS: layout-driven motion, hover states, enter and exit transitions, anything built from rectangles
  • SVG: crisp scalable shapes, path morphing, stroke tracing, masked reveals, icon systems. Every shape is a real DOM node you can target, style and make accessible
  • Canvas: hundreds of moving objects, per-pixel effects, particle fields. There are no DOM nodes, so there is nothing to inspect, style or read out to assistive tech
  • The count is the deciding factor, and the threshold is a heuristic rather than a rule: tens of independently animated shapes are comfortable in SVG, hundreds are not. Profile before switching, because grouping shapes and transforming the group often removes the problem

viewBox: the coordinate system that makes SVG scalable

  • width and height set the size of the SVG viewport. With no viewBox, authored coordinates map straight onto it, so the drawing keeps its authored size and gets clipped when the element is smaller
  • viewBox="minX minY width height" defines an internal coordinate system. The browser maps it onto whatever size the element renders at
  • Author every shape against the viewBox, then size the element in CSS. Never recompute coordinates in JavaScript to fit a container
<svg viewBox="0 0 24 24" class="size-6" fill="none" stroke="currentColor">
  <circle cx="12" cy="12" r="9" stroke-width="2" />
</svg>
  • Pick a round viewBox and keep it consistent across an icon set, for example 0 0 24 24. Mixed coordinate systems make stroke widths inconsistent between icons
  • stroke="currentColor" lets the icon inherit text colour, so it works with semantic tokens instead of a hardcoded fill

preserveAspectRatio and stretched strokes

  • By default SVG preserves the aspect ratio and letterboxes the drawing inside the element
  • preserveAspectRatio="none" lets the graphic stretch to fill, which is useful for decorative dividers, wave separators and full-bleed background shapes
  • Stretching distorts strokes. A vertical line squashed to a fifth of its width renders a fifth as thick, while a horizontal line in the same graphic stays full thickness

Clamp a stretchy divider before it turns into a cliff

  • A full-bleed wave with preserveAspectRatio="none" keeps its height while its width collapses, so the slopes get steeper the narrower the viewport gets. Below roughly half the width it authors for, a gentle swoop reads as a vertical drop
  • Give the svg a percentage width plus a min-width, so it stops squashing and starts overflowing instead
  • Put overflow: hidden on the wrapper so that overflow never produces a horizontal scrollbar, and centre the svg so it spills equally on both sides rather than only to the right
.divider-wrapper {
  display: flex;
  justify-content: center;
  overflow: hidden;
}

.divider {
  width: 100%;
  min-width: 28rem;
  height: 12.5rem;
}

non-scaling-stroke is a choice, not a fix

  • vector-effect: non-scaling-stroke takes the stroke out of the coordinate system. MDN states it directly: the stroke width stops depending on the element's transformations and on the zoom level
  • That is what you want on a stretched graphic, where the stroke would otherwise be thick on one axis and thin on the other
.stretched-graphic :is(path, line, circle, rect, polyline) {
  vector-effect: non-scaling-stroke;
}
  • It fails in the opposite direction on icons. An icon authored in a 0 0 24 24 viewBox with stroke-width="2" means "two units of a twenty-four unit box", and that 2 is supposed to grow when the icon renders large. A non-scaling stroke turns it into two CSS pixels at every size, so the icon renders as hairlines when it is blown up
  • Decide which one you want: a stroke measured in the internal coordinate system, which scales with the drawing, or a stroke measured in CSS pixels, which stays constant. Neither is the safe default

Paths

  • <path d="..."> is the general-purpose shape. Every other primitive is a convenience form of it
  • Commands come in pairs: uppercase is absolute, lowercase is relative to the current point
  • M move, L line, H and V axis-locked lines, C and S cubic curves, Q and T quadratic curves, A arc, Z close
  • Prefer relative commands when a path may be repositioned, and absolute when the coordinates are meaningful anchors

Morph pairs need matching command counts, and you can pad them

  • Two paths tween into each other only when their command sequences match one for one. Mismatched sequences snap instead of flowing
  • Different point counts are not a dead end. Repeat a vertex in the simpler shape until the counts match
  • To morph a three-point triangle into a four-point square, double up one corner of the triangle:
M 5,3
L 19,12
L 19,12
L 5,21
Z
  • The repeated point draws a zero-length line, so the triangle looks unchanged. It gives the square's fourth corner somewhere to slide from, and the morph reads as one shape unfolding rather than three points scrambling
  • Author morph pairs together in the same file so the padding stays in sync when either shape changes

Self-drawing strokes

The most useful path trick: make a line draw itself.

  • stroke-dasharray sets a repeating dash and gap pattern. Setting a single dash as long as the whole path leaves no gap to see
  • stroke-dashoffset then slides that dash along the path. Starting it offset by the full length pushes the stroke out of view, and animating the offset to zero walks it back in, which is what reads as drawing
  • Get the exact length from the element rather than guessing
const length = pathRef.current.getTotalLength();
pathRef.current.style.setProperty("--path-length", `${length}`);
.self-drawing {
  stroke-dasharray: var(--path-length);
  stroke-dashoffset: var(--path-length);
  animation: draw 900ms ease-out forwards;
}

@keyframes draw {
  to {
    stroke-dashoffset: 0;
  }
}
  • The pathLength attribute is the no-JavaScript version. Setting pathLength="1" normalises the path so a dash array of 1 always means the whole path, whatever its real length
  • Reverse the direction by flipping the sign of the offset, or by reversing the path data

Why offset the dash instead of resizing it

  • Animate stroke-dashoffset, not stroke-dasharray. The reason is the dash algorithm: it measures dashes and gaps from the path geometry alone and applies caps afterwards, to whatever segments it produced
  • A zero-length segment with stroke-linecap: round still paints a full circle at that point, so a dasharray animated down to zero never disappears. It leaves a dot, and the reverse animation ends with that dot blinking out
  • Offsetting instead of resizing keeps every dash full size, so the cap behaviour never bites in the middle of the animation
  • One residual dot survives even with the offset approach, at the moment the dash leaves the end of the path. Hide it rather than fight it: mask the visible curve with a copy of the path, and extend the animated path with a short straight stub that starts outside the mask
<svg viewBox="0 0 200 200" fill="none">
  <defs>
    <mask id="swoop-window">
      <path
        d="M 20,180 Q 180,180 180,20"
        fill="none"
        stroke="oklch(1 0 0)"
        stroke-width="12"
      />
    </mask>
  </defs>
  <path
    class="self-drawing"
    mask="url(#swoop-window)"
    pathLength="100"
    d="M 8,180 L 20,180 Q 180,180 180,20"
  />
</svg>
  • The mask traces only the curve the user should see. The animated path carries an extra L 20,180 stub before it, so the dot lands on the stub, outside the mask, invisible
  • Give the mask path a stroke-width comfortably wider than the drawn stroke, or the mask clips the drawn stroke's own edges

Progress rings

  • A <circle> maps to an equivalent path that begins at the three o'clock point and proceeds clockwise, per the SVG specification. Every dash pattern on a circle therefore starts at the right edge, and no attribute changes that
  • For a ring that fills from twelve o'clock, rotate the circle by minus 90 degrees with the origin at its centre
  • pathLength="100" renormalises the circle so a 0 to 100 progress value maps one to one onto stroke-dashoffset, with no circumference arithmetic
<svg viewBox="0 0 240 240" fill="none">
  <circle class="ring-track" cx="120" cy="120" r="100" />
  <circle class="ring-value" cx="120" cy="120" r="100" pathLength="100" />
</svg>
.ring-value {
  stroke-dasharray: 100 1000;
  stroke-dashoffset: calc(100px - var(--progress) * 1px);
  transform: rotate(-90deg);
  transform-origin: 120px 120px;
  transition: stroke-dashoffset 400ms ease-out;
}
  • Set --progress from application state as a plain number from 0 to 100
  • The pixel transform-origin matches the circle's own cx cy. Read the transform origin section below before reaching for transform-origin: center here
  • For a decorative ring of evenly spaced dashes rather than a single sweeping one, the dash length has to divide the circumference exactly. See svg-drawing-fundamentals

Mask-driven motion

Masks are the most powerful animation tool in SVG, because the shapes inside a mask animate with ordinary CSS.

  • A mask hides everything by default. An empty <mask> makes its target completely invisible, which is the single most common reason a masked element vanishes
  • So paint a white rectangle covering the whole viewBox first, then put the black cut-outs on top of it
  • Mask contents obey document-order stacking like every other SVG child. A cut-out written before the white rectangle is painted underneath it and does nothing
  • Mask greyscale maps directly to alpha: white shows the target, black hides it, mid-grey renders it at partial opacity. mask-type="alpha" switches the mask to read its own transparency instead
<svg viewBox="0 0 24 24">
  <defs>
    <mask id="wipe">
      <rect width="24" height="24" fill="oklch(1 0 0)" />
      <circle cx="24" cy="0" r="12" fill="oklch(0 0 0)" />
    </mask>
  </defs>
  <path d="..." mask="url(#wipe)" />
</svg>
  • The common gotcha: transforming a masked element moves the mask with it, so the shape never crosses the boundary. The mask is defined in the element's own coordinate space
  • To slide a shape past a fixed boundary, transform the shape inside a group and apply the mask to the group, so the barrier stays put while the contents move

The anti-aliasing halo

  • A mask shape with exactly the same dimensions as the shape it cuts leaves a faint one-pixel ring behind. Both edges are anti-aliased, and neither partial coverage fully cancels the other
  • Make the mask shape marginally larger. Against a shape at r="50", use r="50.5" in the mask. The half unit is invisible and the ring is gone

Morph by moving a cut-out, not by morphing d

  • A crescent becomes a full disc when you slide the black circle that bites into it out of frame. The disc underneath never changes
  • The reason to prefer this over transitioning d: cx, cy and r are real CSS properties, so they transition everywhere, cost little, and interrupt cleanly halfway through. A d transition does none of those things reliably
<svg class="phase" viewBox="0 0 32 32" aria-hidden="true">
  <defs>
    <mask id="crescent">
      <rect width="32" height="32" fill="oklch(1 0 0)" />
      <circle class="phase-cutout" cx="24" cy="8" r="12" fill="oklch(0 0 0)" />
    </mask>
  </defs>
  <circle
    class="phase-disc"
    cx="16"
    cy="16"
    r="12"
    fill="currentColor"
    mask="url(#crescent)"
  />
</svg>
.phase {
  transition: transform 500ms cubic-bezier(0.2, 0.8, 0, 1);
}

.phase-cutout,
.phase-disc {
  transition:
    cx 500ms cubic-bezier(0.2, 0.8, 0, 1),
    cy 500ms cubic-bezier(0.2, 0.8, 0, 1),
    r 500ms cubic-bezier(0.2, 0.8, 0, 1);
}

[data-state="day"] .phase {
  transform: rotate(90deg);
}

[data-state="day"] .phase-cutout {
  cx: 44px;
  cy: -4px;
}

[data-state="day"] .phase-disc {
  r: 10px;
}

Two craft details carry this effect:

  • Shrink the base radius slightly in the state that gains surrounding detail, so the icon's optical size stays constant. A disc plus rays reads as bigger than a bare crescent at the same radius, and the eye notices the growth even when nothing moved
  • Rotate the parent <svg> across the same transition. On its own the cut-out travels in a straight line, which reads as mechanical. Rotating the frame underneath it turns the same straight travel into a spiral

Pop from the inside out

  • Run one grow keyframe on both the visible shape and on a black copy inside its mask, and delay the mask copy
  • The mask then eats the shape from the centre outward, so the ring thins and vanishes instead of fading as a solid disc
<svg viewBox="0 0 40 40" fill="none" aria-hidden="true">
  <defs>
    <mask id="pop">
      <rect width="40" height="40" fill="oklch(1 0 0)" />
      <circle class="pop-cutout" cx="20" cy="20" r="0" fill="oklch(0 0 0)" />
    </mask>
  </defs>
  <circle
    class="pop-ring"
    cx="20"
    cy="20"
    r="0"
    fill="oklch(0.8 0.14 300)"
    mask="url(#pop)"
  />
</svg>
@keyframes pop-grow {
  to {
    r: 20px;
  }
}

@keyframes pop-grow-cutout {
  to {
    r: 20.5px;
  }
}

.pop-ring {
  animation: pop-grow 600ms cubic-bezier(0.2, 0.8, 0, 1) forwards;
}

.pop-cutout {
  animation: pop-grow-cutout 600ms 140ms cubic-bezier(0.2, 0.8, 0, 1) forwards;
}
  • The cut-out ends half a unit larger than the ring, which is the halo fix above. Without it a faint circle of pixels stays on screen after the animation finishes

Fill meters that survive any background

  • The tempting way to show a shape half filled is to overlay a background-coloured copy of it. That breaks the moment the page has a gradient, an image, or a second theme
  • Put the shape's path inside a mask as white, then render two full-viewBox rectangles through that mask: a neutral one for the empty state, and a coloured one whose vertical position animates
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
  <defs>
    <mask id="heart-mask">
      <path d="..." fill="oklch(1 0 0)" />
    </mask>
  </defs>
  <rect
    width="24"
    height="24"
    fill="oklch(0.92 0.01 60)"
    mask="url(#heart-mask)"
  />
  <rect
    class="meter-level"
    y="24"
    width="24"
    height="24"
    fill="oklch(0.62 0.22 20)"
    mask="url(#heart-mask)"
  />
</svg>
.meter-level {
  transition: y 220ms ease-out;
}
const level = document.querySelector(".meter-level");
const TOP = 3.9;
const BOTTOM = 21.8;

function setLevel(ratio) {
  const eased = ratio ** 0.5;
  level.style.y = `${BOTTOM - eased * (BOTTOM - TOP)}px`;
}
  • TOP and BOTTOM are the real extents of the artwork, not of the viewBox. Measure them by hand once, or the fill appears to start late and finish early
  • The exponent below 1 front-loads the fill, so the first increments move visibly and the last ones creep. That reads as rewarding rather than lacklustre, and it matches a shape that narrows toward the bottom, where the same volume covers more height
  • Nothing here depends on the page background, because the coloured rectangle is clipped by the artwork rather than sitting behind a decoy

Transform origin in SVG

  • CSS transforms apply to SVG children, but the origin does not default to the centre of the shape. For child elements transform-origin defaults to 0 0 in the SVG's own coordinate system, which is the visual top-left only when the viewBox also starts at 0 0
  • A root <svg> element is the exception and behaves like any HTML box, defaulting to 50% 50%
  • Reaching for transform-origin: center does not fix it, and the reason matters: percentages and keywords resolve against the element's reference box, and transform-box defaults to view-box for SVG children. That reference box is the viewBox, which every element in the drawing shares. So center resolves to the centre of the whole drawing, and a rotation authored for a centred spin orbits that point instead

Two fixes, both fine:

.spinner-arc {
  transform-box: fill-box;
  transform-origin: center;
  animation: spin 1s linear infinite;
}
.dial-needle {
  transform-origin: 120px 120px;
}
  • transform-box: fill-box re-points the reference box at the element's own bounding box, so keywords and percentages start meaning what you expect
  • An explicit pixel origin in viewBox coordinates is the alternative, and it is practical here in a way it never is in HTML, because SVG already positions everything absolutely

Computing the pixel origin depends on the shape:

  • <circle>, <ellipse> and <text> are positioned by their own anchor point, so the origin is cx cy or x y
  • <rect> is anchored top-left, so the centre is x + width / 2 and y + height / 2
  • <line> uses the midpoint of its two endpoints
  • <path>, <polygon> and <polyline> have no formula. Compute the centroid in whatever code generates the shape, or read getBBox() and take the middle of that
  • In React, derive the geometry and the origin from the same variables so they cannot drift apart
const cx = 150;
const cy = 100;

<circle cx={cx} cy={cy} r={40} style={{ transformOrigin: `${cx}px ${cy}px` }} />;
  • Wrapping several shapes in a <g> and transforming the group is the alternative when you need one origin shared by many shapes

Separate placement from motion

When a reusable <use> instance is positioned with a transform attribute, a CSS animation that also writes transform replaces that placement instead of composing with it. The instance can jump back to the origin or lose its scale as soon as the animation starts.

Put the placement transform on an outer group and animate the nested shape:

<g transform="translate(94 85) scale(0.55)">
  <use class="twinkling-star" href="#star" />
</g>

The outer group owns position and scale; the inner element owns motion. Use the same nesting for reusable stars, particles and illustration details whenever placement and animation both need transforms.

Connecting two live elements with one SVG

A single static SVG can bridge two elements that move independently, without regenerating path data on every frame.

  • Author a fixed square viewBox holding one static swoop. That path never changes
  • Position and size the svg from both elements' bounding boxes, then let preserveAspectRatio="none" distort the square into whatever box the two endpoints define
<svg class="connector" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
  <path d="M 0,0 C 0,50 100,50 100,100" />
</svg>
.connector-wrapper {
  position: relative;
}

.connector {
  position: absolute;
  fill: none;
  overflow: visible;
}

.connector path {
  stroke: currentColor;
  stroke-width: 2px;
  stroke-dasharray: 5px 8px;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
const MIN_SPAN = 1;

function positionConnector() {
  const from = start.getBoundingClientRect();
  const to = end.getBoundingClientRect();
  const frame = wrapper.getBoundingClientRect();

  const fromX = from.left + from.width / 2 - frame.left;
  const toX = to.left + to.width / 2 - frame.left;
  const fromY = from.bottom - frame.top;
  const toY = to.top - frame.top;

  connector.style.left = `${Math.min(fromX, toX)}px`;
  connector.style.width = `${Math.max(Math.abs(toX - fromX), MIN_SPAN)}px`;
  connector.style.top = `${Math.min(fromY, toY)}px`;
  connector.style.height = `${Math.max(Math.abs(toY - fromY), MIN_SPAN)}px`;
}

positionConnector();
window.addEventListener("resize", positionConnector);

Every number the svg needs comes from the two measured boxes:

  • left and width span the two horizontal centres, so the curve starts and ends under the middle of each element
  • top and height span the vertical gap, from the bottom edge of one element to the top edge of the other
  • The stylesheet sets no width and no height. Both dimensions are measured, and a hardcoded fallback would silently win whenever the real gap differed from it, leaving the curve stopping short of its end point
  • Both dimensions clamp to at least one pixel. Two endpoints that line up exactly on an axis measure a span of zero, and an SVG with a zero-width or zero-height viewport paints nothing at all, so the connector would vanish at the moment it should read as a straight line

The example recalculates on resize and nothing else, which is the limitation to plan around:

  • Endpoints move for reasons that never fire resize: a drag, a layout change, an accordion opening above the pair, a CSS transform. The connector stays at its last measured box until something calls the function again
  • Call positionConnector from whatever updates the endpoints, add a ResizeObserver when the elements themselves change size, and drive it from the animation loop when the motion is transform-driven

Each attribute earns its place:

  • preserveAspectRatio="none" lets the square viewBox squash independently on both axes, into whatever box the four measurements define
  • vector-effect: non-scaling-stroke stops the dash pattern smearing along either stretched axis, which otherwise turns neat dots into streaks
  • overflow: visible on the svg keeps the stroke edges from being clipped at the box boundary, since inline SVG viewports clip by default

The design detail worth stating: put the first control point directly below the start point and the second directly above the end point, so both share an x coordinate with the point they belong to. As the two elements converge horizontally the box narrows to the one-pixel floor. The points do not literally meet, because preserveAspectRatio="none" still maps the full viewBox across that pixel, but the whole curve compresses into a one-pixel band and reads as a straight vertical line. Driving the height the same way extends that property to the other axis: as the vertical gap closes the box flattens to the same floor and the curve reads as a straight horizontal line. The clamp is the only guard the geometry needs, and it costs nothing visible, since a one-pixel span already reads as the straight line it is. No special case, no threshold to tune.

The trade-off: a fixed viewBox always bends the same way, so the curve bends wrong when the endpoints swap sides and the top element ends up on the right. Computing the path data in screen pixels handles either orientation and drops the need for preserveAspectRatio and vector-effect entirely, at the cost of doing the geometry in JavaScript. Pick the fixed viewBox when the layout guarantees an order, and the computed path when it does not.

Progress arcs built from discrete segments

A progress arc drawn as one stroked path can only be one colour and can only animate as one unit. Build it from many short <line> elements instead and each segment becomes independently colourable and independently staggerable.

  • Map the segment index across the angular range you want, then convert polar to cartesian for both the inner and outer endpoint of each segment
  • Polar conversion is origin-relative: every coordinate comes back measured from (0, 0). Offset each one to move the pivot from the corner to the intended centre, which for a top half arc is the middle of the bottom edge
const SVG_NS = "http://www.w3.org/2000/svg";
const WIDTH = 200;
const HEIGHT = 100;
const SEGMENT_LENGTH = 20;
const COUNT = 100;

function polarToCartesian(degrees, distance) {
  const radians = (degrees * Math.PI) / 180;
  return [Math.cos(radians) * distance, Math.sin(radians) * distance];
}

function drawArc(score) {
  for (let i = 0; i < COUNT; i++) {
    const angle = 180 + (i / (COUNT - 1)) * 180;
    const [innerX, innerY] = polarToCartesian(angle, WIDTH / 2 - SEGMENT_LENGTH);
    const [outerX, outerY] = polarToCartesian(angle, WIDTH / 2);

    const line = document.createElementNS(SVG_NS, "line");
    line.setAttribute("x1", WIDTH / 2 + innerX);
    line.setAttribute("y1", HEIGHT + innerY);
    line.setAttribute("x2", WIDTH / 2 + outerX);
    line.setAttribute("y2", HEIGHT + outerY);
    line.setAttribute("data-filled", i < score);
    line.style.setProperty("--delay", `${i * 12}ms`);
    svg.append(line);
  }
}
line {
  stroke: oklch(0.55 0.02 250);
  stroke-width: 2px;
}

line[data-filled="true"] {
  animation: fill-segment 200ms var(--delay) ease-out forwards;
}

@keyframes fill-segment {
  to {
    stroke: oklch(0.82 0.17 85);
  }
}
  • Angles from 180 to 360 sweep the upper half, because y grows downward in SVG coordinates
  • Debugging tip: set the inner distance to 0 first. That draws a filled wedge, which proves the fan is centred and oriented correctly. Only once the wedge looks right should you raise the inner distance to hollow it into an arc
  • Stagger with a per-segment --delay. Growing the delay faster than linearly makes the sweep decelerate, which reads as a value settling rather than a bar sliding
  • document.createElementNS with the SVG namespace is mandatory here. document.createElement("line") produces an inert unknown element that never renders

CSS or JavaScript

  • CSS handles the majority: hover states, loops, enter and exit animations, anything with a fixed start and end. It runs off the main thread where the property allows and needs no runtime
  • JavaScript earns its place for values you cannot know ahead of time, interruption and velocity handling, spring physics, and sequencing tied to scroll or pointer position
  • Presentation attributes lose to CSS rules. If a fill attribute appears to be ignored, a stylesheet is overriding it

Which geometry you can actually animate in CSS

There is no single answer for "geometry attributes". There are three tiers, and they behave completely differently.

  • Single-value geometry attributes are real CSS properties. SVG2 defines x, y, width, height, cx, cy, r, rx and ry as geometry properties, so a CSS rule overrides the attribute and a CSS transition interpolates it like any other length. This is what makes mask-driven morphing cheap
  • d is a CSS property on <path>, but not everywhere. Chromium and Firefox support it, including transitions between two path() values with matching command counts. MDN's compatibility data records that Safari parses the property and it has no effect, so both the override and the transition silently do nothing there. Treat cross-browser path morphing as a JavaScript animation library's job
  • points and the line endpoints are not CSS properties at all. points on <polyline> and <polygon>, and x1, y1, x2, y2 on <line>, exist only as attributes. They are animatable by setting the attribute from JavaScript, but no CSS rule will ever apply to them. DevTools shows them struck through as unknown properties, which is the fastest way to confirm what you are looking at
.morphing-path {
  transition: d 300ms ease-out;
}

.morphing-path:hover {
  d: path("\
    M 20,50 \
    C 80,0 140,100 180,50 \
  ");
}
  • d: path("...") takes a quoted SVG path data string. The value is a CSS string, and CSS strings cannot contain a raw newline, so a multi-line path needs a backslash as the last character of every line or the whole declaration is invalid and gets dropped
  • Nothing may follow the backslash, not even a space

Always write the unit suffix

  • The CSS geometry properties take a <length-percentage>. A bare number is not a valid CSS length, so r: 50 inside a keyframe is an invalid declaration the browser throws away, while r: 50px works
  • This trips people because the SVG attribute does take a bare number, and engines differ in how forgiving they are about the CSS form. Write px on every SVG length you set from CSS and the inconsistency disappears

SVG in React

  • SVG in JSX is close to plain markup, with camelCased attributes such as strokeWidth and clipPath
  • The trap is ids. Masks, gradients, filters and <use> all reference elements by id, but a React component is meant to render many times. Duplicate ids mean the second instance silently borrows the first instance's definitions
  • Generate an id per instance with useId and interpolate it into both the definition and the reference
import { useId } from "react";

function MaskedIcon() {
  const id = useId();
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <defs>
        <mask id={`${id}-wipe`}>
          <rect width="24" height="24" fill="oklch(1 0 0)" />
        </mask>
      </defs>
      <path d="..." mask={`url(#${id}-wipe)`} />
    </svg>
  );
}
  • The same rule covers gradients and filters. Any url(#...) reference inside a reusable component needs a per-instance id
  • Inline the SVG rather than loading it through <img> when it has to animate or inherit colour. An SVG in an <img> is opaque to CSS and JavaScript

Accessibility

  • Decorative SVG takes aria-hidden="true" and no accessible name. Most icons next to a text label are decorative
  • Meaningful SVG takes role="img" and a <title> as the first child, referenced with aria-labelledby
  • An icon-only control takes its label from the button, not the graphic. Give the button aria-label and hide the SVG
  • Every looping or attention-seeking animation needs a reduced-motion path that ends in the final state rather than removing the information
  • Declare the destination in the ordinary rule and put the journey, the animation and the transition, inside @media (prefers-reduced-motion: no-preference). The feature has exactly two values, so a browser that cannot evaluate the query matches neither and paints the destination. accessibility.md has the argument in full
.self-drawing {
  stroke-dasharray: var(--path-length);
  stroke-dashoffset: 0;
}

line[data-filled="true"] {
  stroke: oklch(0.82 0.17 85);
}

@media (prefers-reduced-motion: no-preference) {
  .self-drawing {
    stroke-dashoffset: var(--path-length);
    animation: draw 900ms ease-out forwards;
  }

  .ring-value {
    transition: stroke-dashoffset 400ms ease-out;
  }

  .meter-level {
    transition: y 220ms ease-out;
  }

  .phase {
    transition: transform 500ms cubic-bezier(0.2, 0.8, 0, 1);
  }

  .phase-cutout,
  .phase-disc {
    transition:
      cx 500ms cubic-bezier(0.2, 0.8, 0, 1),
      cy 500ms cubic-bezier(0.2, 0.8, 0, 1),
      r 500ms cubic-bezier(0.2, 0.8, 0, 1);
  }

  line[data-filled="true"] {
    stroke: oklch(0.55 0.02 250);
    animation: fill-segment 200ms var(--delay) ease-out forwards;
  }
}
  • Reduced motion removes the journey, never the destination. A self-drawing stroke ends fully drawn, a progress ring still shows its value, a fill meter still shows its level, and a segmented arc paints every filled segment at once
  • The state-driven cases need nothing outside the gate. .ring-value, .meter-level and the phase icon read their values from application state, so with no transition to run they jump straight to the current value
  • The filled segment needs its unlit colour restated inside the gate. fill-segment has only a to block, so it takes its start from the element's computed style, and the ungated rule now holds the lit colour. Without that line the animation would start and end on the same colour and nothing would appear to happen
  • The pop classes belong inside the gate too, and for a sharper reason than the rest. Their animation starts at r: 0, so an ungated rule that never animates would leave a shape at zero radius that never appears. An effect whose only state is transient gets skipped, not frozen
const motionOk = window.matchMedia("(prefers-reduced-motion: no-preference)");

function celebrate() {
  if (!motionOk.matches) return;
  popRing.classList.add("is-popping");
}
  • That is safe here because the pop decorates a state change the user can already see. If it were the only feedback, the reduced-motion branch would need a static equivalent instead of nothing
  • Where the motion carries meaning that no static frame conveys, state the meaning in text as well, so the information does not live only in the animation

Common mistakes

  • Fixed width and height with no viewBox, so the drawing cannot adapt to the element and gets clipped
  • Hardcoded hex fills instead of currentColor or a semantic token, so the icon ignores the theme
  • Reaching for transform-origin: center on an SVG child and getting a rotation that flies across the screen, because the reference box is the shared viewBox rather than the shape
  • Duplicate mask and gradient ids in a reusable component, which breaks every instance after the first
  • Animating d between paths with different command sequences, which snaps instead of morphing, when repeating a vertex would have made them match
  • Writing a CSS d transition and calling path morphing solved, when it does nothing at all in Safari
  • Writing a CSS rule against points or x1 and wondering why it is ignored. Those are attributes only
  • Unitless lengths in CSS, such as r: 50, which are invalid declarations and get dropped
  • An empty or all-black <mask>, which makes the target completely invisible. Paint a white rectangle over the viewBox first
  • A mask cut-out sized exactly like the shape it cuts, leaving a faint ring of anti-aliased pixels behind
  • Stretched graphics with no vector-effect: non-scaling-stroke, which gives strokes uneven thickness, and icons that do have it, which renders them as hairlines when scaled up
  • A full-bleed stretched divider with no min-width, which turns into a near-vertical cliff on narrow viewports

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "svg-animation" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems