Morphing SVG Paths with the d Property

Part of Animating SVG with CSS in Core CSS Animation Fundamentals.

The problem

A play button should become a pause button, a plus should become a minus, a hamburger should become a close icon — not by cross-fading two icons, but by the shape itself bending into the new one. CSS can do this with the d property, which exposes an SVG path’s geometry as an animatable value. The first attempt usually fails in a confusing way: the transition is declared, the Animations drawer shows it running, and the shape still jumps from one state to the other exactly halfway through.

Root cause analysis: interpolation happens command by command

A path’s d value is a list of commands — move, line, cubic curve, arc, close — each with numeric arguments. When the browser interpolates between two d values, it pairs the first command of one path with the first command of the other, the second with the second, and so on, and interpolates their numbers.

That only works if the lists are structurally compatible: the same number of commands, of the same types, in the same order. Browsers normalise some differences first — relative and absolute forms of the same command are compatible — but a path with four line commands cannot interpolate with a path made of two curves and an arc. When the structures differ, the property falls back to discrete animation and flips at 50%.

Even with compatible structures, the result depends on correspondence. If the play triangle starts at its left edge and the pause bar starts at its right edge, every point travels across the icon during the morph and the shape turns inside out halfway. Good morphs start both paths at corresponding points and trace them in the same direction.

How the browser decides whether a d transition can morphAnything short of full structural compatibility falls back to a discrete swap at the midpoint.How the browser decides whether a d transition can morphTwo d valuesNormalise commandsSame structure:morphDifferent: flip at50%transition startsmatchmismatch
Anything short of full structural compatibility falls back to a discrete swap at the midpoint.

Step-by-step resolution

Preparing two shapes that can morphMost of the work happens in the vector editor, not in CSS.Preparing two shapes that can morph1Draw both states on the same viewBox and grid.Coordinates are directly comparable2Give both shapes the same number of anchor points, adding points to the simpler one.Command counts can match3Convert every segment in both paths to cubic curves.Command types match4Start both paths at the corresponding anchor and draw in the same direction.Points travel short distances5Copy both d strings into CSS as path() values.Each state is a declaration6Leave the default state's d attribute in the markup as the fallback.
Most of the work happens in the vector editor, not in CSS.

Converting everything to cubic curves is the dependable way to make command types match: a straight line is a cubic whose control points sit on the line, so it looks identical but interpolates with any other cubic. Most vector editors can export paths in this form, and an optimiser that converts curves back to lines should be turned off for morph targets.

Production code pattern

<button class="media-toggle" aria-pressed="false" aria-label="Play">
  <svg viewBox="0 0 24 24" aria-hidden="true">
    <!-- Default (play) geometry in the attribute: correct without CSS d support. -->
    <path class="media-toggle__left"  d="M7 5 C7 5 12 8.5 12 8.5 C12 8.5 12 15.5 12 15.5 C12 15.5 7 19 7 19 Z" />
    <path class="media-toggle__right" d="M12 8.5 C12 8.5 17 12 17 12 C17 12 17 12 17 12 C17 12 12 15.5 12 15.5 Z" />
  </svg>
</button>
.media-toggle path {
  fill: currentColor;
  transition: d 200ms cubic-bezier(0.3, 0.7, 0.2, 1);
}

/* Play: two halves of a triangle. Each path is three cubics and a close. */
.media-toggle__left  { d: path("M7 5 C7 5 12 8.5 12 8.5 C12 8.5 12 15.5 12 15.5 C12 15.5 7 19 7 19 Z"); }
.media-toggle__right { d: path("M12 8.5 C12 8.5 17 12 17 12 C17 12 17 12 17 12 C17 12 12 15.5 12 15.5 Z"); }

/* Pause: two bars, with the same command structure as the play halves. */
[aria-pressed="true"] .media-toggle__left  { d: path("M7 5 C7 5 10 5 10 5 C10 5 10 19 10 19 C10 19 7 19 7 19 Z"); }
[aria-pressed="true"] .media-toggle__right { d: path("M14 5 C14 5 17 5 17 5 C17 5 17 19 17 19 C17 19 14 19 14 19 Z"); }

@media (prefers-reduced-motion: reduce) {
  .media-toggle path { transition: none; }     /* shapes swap instantly */
}
button.addEventListener('click', () => {
  const playing = button.getAttribute('aria-pressed') !== 'true';
  button.setAttribute('aria-pressed', String(playing));
  button.setAttribute('aria-label', playing ? 'Pause' : 'Play');
});

Rendering Impact: paint, confined to a 24px icon. Every frame computes new path geometry and repaints the icon; the region is small enough that the cost is negligible, but the same technique on a full-width illustration would not be.

Splitting the triangle into two halves is the trick that makes play-to-pause work. A single triangle cannot become two separate bars, because one closed path cannot turn into two. Two paths that happen to meet in the middle can each become one bar.

Two ways to build the play and pause morphStructure decides whether the icon morphs or swaps.Two ways to build the play and pause morphOne triangle, two rectsPath 1: M, L, L, Z (triangle)Paths 2 and 3: rects with H and V commandsDifferent counts and command typesSwaps at 50%Two halves, two barsEach path: M, C, C, C, Z in both statesStarting points correspondSame drawing directionSmooth morph
Structure decides whether the icon morphs or swaps.

Where d is not supported

The d property is animatable in Chromium-based browsers and Firefox, but not everywhere. In an engine that does not recognise it, the d: declarations are invalid and ignored, so the paths use their d attributes — the play shape — in both states. That would leave the icon stuck on play.

Two approaches handle it. The simplest is to accept a non-morphing swap: keep two separate icons in the button and cross-fade them with opacity, then use @supports (d: path("M0 0")) to hide the second icon and enable the morph where the property exists. Alternatively, drive the geometry from script with the Web Animations API on engines that support animating d there, and update the attribute directly elsewhere. The @supports version keeps the logic in CSS and fails safe.

.media-toggle .icon-pause { opacity: 0; }
[aria-pressed="true"] .icon-play  { opacity: 0; }
[aria-pressed="true"] .icon-pause { opacity: 1; }

@supports (d: path("M0 0")) {
  .media-toggle .icon-pause { display: none; }            /* the morphing paths handle both states */
  [aria-pressed="true"] .icon-play { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .media-toggle :is(.icon-play, .icon-pause) { transition: none; }
}

Verification checklist

Constraints and trade-offs

  • Morphs are paint animations; keep them to icon-sized graphics.
  • Path optimisers that shorten output — converting curves to lines, merging commands — break compatibility; exclude morph targets from optimisation.
  • Very different shapes, such as a circle into a star, need many added anchor points and still tend to look muddy midway; a cross-fade or a scale-and-rotate often reads better.
  • Topology cannot change: a path cannot gain or lose a hole or split into two during a morph without separate paths.
  • Keeping geometry in CSS duplicates it from the markup; generate both from one source if icons change often.

Frequently asked questions

Why does my d transition swap halfway instead of morphing?

The two path values are not structurally compatible. They need the same number of commands of the same types in the same order. Convert both to cubic curves with the same anchor count and they will interpolate.

Can I morph between paths with different numbers of points?

Not directly. Add anchor points to the simpler shape in your editor until both have the same count, placing the extra points where they will travel naturally to their counterparts.

Is animating d better than cross-fading two icons?

It looks more physical for related shapes, such as play and pause or plus and minus. For unrelated shapes a cross-fade is cheaper, simpler and often clearer. Both are small paint animations when kept to icon size.

Can I animate d with the Web Animations API?

Yes, in engines that support d as a CSS property: pass path() strings as keyframe values to element.animate(). The same compatibility rules apply.