SVG Morphs, Clip-paths

Morphing svgs takes no library - SVG <path> and clip-path: polygon()

Work in progress

This article is still being written and may change.

A pause icon is two bars; a play icon is a triangle. Animating one into the other looks like it needs a clever tweening library, but it doesn't - it needs one rule followed. Below is the whole build, step by step, two ways: an SVG <path> that morphs, and CSS clip-path. Click either icon to see the end result.

Click an icon

Step 1 - Put both icons on one grid

The constraint that makes everything work: you can only interpolate between two shapes if they have the same number of points, in the same order. The browser tweens by walking both point lists in lockstep - mismatched lengths and it gives up. So before any animation, draw both states on one coordinate grid and make the point counts match.

Use a 100 × 100 grid. The pause is two bars - eight points. The play triangle has three, so split it down the middle into two four-point shapes: the left bar becomes the left part (a trapezoid running to the centre line), the right bar becomes the tip. Now both states are 4 + 4 points, and each bar slides straight across into its own half.

// 100×100 grid - each shape is 4 points, top-left then clockwise

//        pause                     play
// left  30,22 43,22 43,78 30,78   30,22 52,36 52,64 30,78   → left part
// right 57,22 70,22 70,78 57,78   52,36 74,50 74,50 52,64   → the tip

Notice the tip repeats 74,50 - the apex is one point, written twice, so the shape still has four. That's the trick for matching counts whenever one shape has fewer real corners than the other.

Step 2 - Morph it as an SVG path

Turn each grid into a path string with the exact same command sequence - M L L L Z, twice. Hold a playing boolean, swap the d between the two strings, and let CSS animate it: d is an animatable property, so a one-line transition does all the morphing.

const PAUSE = "M30,22 L43,22 L43,78 L30,78 Z M57,22 L70,22 L70,78 L57,78 Z"
const PLAY  = "M30,22 L52,36 L52,64 L30,78 Z M52,36 L74,50 L74,50 L52,64 Z"

function PlayPause() {
  const [playing, setPlaying] = useState(false)
  return (
    <svg viewBox="0 0 100 100" onClick={() => setPlaying(p => !p)}>
      <path fill="#17c3fa"
            d={playing ? PLAY : PAUSE}
            style={{ transition: "d 0.4s ease" }} />
    </svg>
  )
}

That's the entire SVG version. One node, resolution-independent. The catch: animating the d property is Chromium/Safari only - Firefox doesn't tween it yet. For cross-browser, feed the same two strings to motion (<motion.path animate={{ d }} />), a SMIL <animate>, or a path-morph library like flubber.

Step 3 - Or build it with clip-path

A single polygon() can't carve two separated bars (you'd need a concave shape bridging the gap). So use two solid-colour boxes and clip each one to a four-point polygon - the same coordinates as Step 1, written as percentages. Toggle each box's clip-path and transition it. Because the numbers match the path version, the two icons are pixel-identical.

function PlayPause() {
  const [playing, setPlaying] = useState(false)
  const bar = { position: "absolute", inset: 0, background: "#17c3fa",
               transition: "clip-path 0.4s ease" }
  return (
    <div onClick={() => setPlaying(p => !p)}
         style={{ position: "relative", width: 48, height: 48 }}>
      // left bar → left part
      <div style={{ ...bar, clipPath: playing
        ? "polygon(30% 22%, 52% 36%, 52% 64%, 30% 78%)"
        : "polygon(30% 22%, 43% 22%, 43% 78%, 30% 78%)" }} />
      // right bar → the tip
      <div style={{ ...bar, clipPath: playing
        ? "polygon(52% 36%, 74% 50%, 74% 50%, 52% 64%)"
        : "polygon(57% 22%, 70% 22%, 70% 78%, 57% 78%)" }} />
    </div>
  )
}

Step 4 - Round every corner with one filter

Both versions have sharp corners, and neither a tweening polygon() nor a tweening d can carry per-corner radii. So don't round the geometry - round the render with a small SVG goo filter. feGaussianBlur softens every edge, then feColorMatrix re-hardens the alpha channel into a crisp outline. It leaves R/G/B untouched, so the fill colour survives. Drop this <filter> once anywhere in the page, then point both icons at it with filter: url(#pp-round).

<filter id="pp-round">
  <feGaussianBlur stdDeviation="1.4" />          // blur out the corners
  <feColorMatrix values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -8" />
</filter>

// the last matrix row sharpens alpha back to a hard edge:
// new_alpha = 22 × alpha − 8  → small radius stays, fill colour kept

// on each icon's root element:  filter: url(#pp-round)

Tune the look with two numbers: stdDeviation sets how round (bigger = softer), and the alpha multiplier in the matrix sets how crisp the re-hardened edge is. Because it's just a filter sitting on top, it rounds the bars, the trapezoid and the tip alike - live, through the whole morph.

Step 5 - The same trick on a button

This isn't a toy. The exact lockstep rule powers the hover on Base's "Get started" button - a chevron that grows into an arrow . Hover it:

Hover the button

Frame the arrow as a shaft plus a > head. The chevron is just the head - so collapse the shaft to a zero-length point sitting on the head's tip, exactly like the play tip's doubled apex back in Step 1. Both strings are then M L M L L, identical commands, and a d transition tweens the shaft straight out of the chevron. Nudge the whole icon a few pixels right on hover for the slide.

// shaft collapsed onto the tip → looks like just  ›
const CHEVRON = "M13.5,10 L13.5,10 M7.5,4 L13.5,10 L7.5,16"
// shaft runs out to x=3        → the full  →
const ARROW   = "M3,10 L13.5,10 M7.5,4 L13.5,10 L7.5,16"

<path stroke="currentColor" strokeWidth={2.6} strokeLinecap="round"
      d={hover ? ARROW : CHEVRON}
      style={{ transition: "d 0.35s ease" }} />

Base ships the same look with a close cousin instead of a morph: a static chevron head, plus a separate shaft line that draws on with stroke-dashoffset (stroke-dasharray: 10.5, offset animated to 0 on hover). Either reads as - the morph keeps it to one path and one animatable property; the dash-offset draw works in Firefox too, where d doesn't tween yet.

Which one should you ship?

The clip-path version wins on reach: polygon() transitions work in every modern engine, and because it clips any element, you can carve the play triangle out of an image, a gradient, or a video thumbnail - not just a flat fill. The cost is two DOM nodes. The SVG morph is a single scalable node with cleaner markup, but it leans on d-property animation (or a JS helper) to run everywhere. Same geometry, same eight points, same rounding filter - pick by what you're filling and which browsers you owe.

Newsletter

Stay updated with my latest articles and projects. No spam, no nonsense.