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 tipNotice 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.