Back to the library
Icon morph — play and pause
Variant: Play and pause
The one in the family that moves geometry instead of a transform: two bars and a triangle share no transform that gets you from one to the other, so both poses are written as four-point paths and the shape is split down the middle. The left half goes bar to trapezoid, the right half collapses its outer edge onto the apex. Transport controls are pressed constantly, which is why the whole thing is 288ms and symmetric — the fastest morph you would still call a morph.
controliconmorphsvg
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Icon morph — play and pause</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — every duration in the document runs off --t. Same beat map as
the rest of the family: press at 8%, morph 11-19%, return 58-66%. */
--cycle: 3.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-accent: cubic-bezier(0.32, 0.72, 0, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.chip {
display: grid;
place-items: center;
width: clamp(72px, 20vw, 96px);
aspect-ratio: 1;
border-radius: calc(18px * var(--round));
background: var(--paper);
box-shadow: inset 0 0 0 1px var(--oat);
animation: press var(--t) infinite;
}
.icon {
display: block;
width: 58%;
height: 58%;
}
/* The stroke is not an outline — it is the corner radius. A 1.4 round join
painted in the fill colour softens four corners and the apex at once,
and unlike an rx it survives a shape whose corners are moving. */
.icon path {
fill: var(--slate);
stroke: var(--slate);
stroke-width: 1.4;
stroke-linejoin: round;
}
.left {
animation: to-play-left var(--t) infinite;
}
.right {
animation: to-play-right var(--t) infinite;
}
/* Only ever visible under reduced motion. */
.pose-b {
opacity: 0;
}
@keyframes press {
0%,
8% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
11% {
transform: scale(0.955);
animation-timing-function: var(--ease-out);
}
16%,
55% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
58% {
transform: scale(0.955);
animation-timing-function: var(--ease-out);
}
63%,
100% {
transform: scale(1);
}
}
/* A real path morph, and the only place in this family where geometry
moves rather than a transform: two bars and a triangle share no
transform that gets you from one to the other. `d` is paint-only — it
re-rasterises the shape and touches no layout — but it interpolates
point by point, so both poses are written as four-point paths in the
same order. The shape is split down the middle at x=12: the left half
goes bar to trapezoid, the right half collapses its outer edge to the
apex. Where a browser cannot interpolate `d`, the control snaps between
the two states on the same beats, which is a correct pause button. */
@keyframes to-play-left {
0%,
11% {
d: path('M7 5.5 L10.6 5.5 L10.6 18.5 L7 18.5 Z');
animation-timing-function: var(--ease-accent);
}
19%,
58% {
d: path('M7 5.5 L12 8.21 L12 15.79 L7 18.5 Z');
animation-timing-function: var(--ease-accent);
}
66%,
100% {
d: path('M7 5.5 L10.6 5.5 L10.6 18.5 L7 18.5 Z');
}
}
/* The doubled apex point is deliberate: the right bar's two outer corners
both land on (19, 12), which is how a four-point path becomes a
triangle without changing its command list mid-flight. */
@keyframes to-play-right {
0%,
11% {
d: path('M13.4 5.5 L17 5.5 L17 18.5 L13.4 18.5 Z');
animation-timing-function: var(--ease-accent);
}
19%,
58% {
d: path('M12 8.21 L19 12 L19 12 L12 15.79 Z');
animation-timing-function: var(--ease-accent);
}
66%,
100% {
d: path('M13.4 5.5 L17 5.5 L17 18.5 L13.4 18.5 Z');
}
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent. The geometry stops moving and the two poses
cross-fade on the beats the morph was using. */
.chip {
animation: none;
}
.pose-a path {
animation: none;
}
.pose-a {
animation: cross-out calc(var(--t) * 1.8) ease infinite;
}
.pose-b {
animation: cross-in calc(var(--t) * 1.8) ease infinite;
}
@keyframes cross-out {
0%,
11% {
opacity: 1;
}
19%,
58% {
opacity: 0;
}
66%,
100% {
opacity: 1;
}
}
@keyframes cross-in {
0%,
11% {
opacity: 0;
}
19%,
58% {
opacity: 1;
}
66%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div class="chip" role="img" aria-label="A pause control morphing into a play control">
<svg class="icon" viewBox="0 0 24 24">
<g class="pose-a">
<path class="left" d="M7 5.5 L10.6 5.5 L10.6 18.5 L7 18.5 Z" />
<path class="right" d="M13.4 5.5 L17 5.5 L17 18.5 L13.4 18.5 Z" />
</g>
<path class="pose-b" d="M7 5.5 L19 12 L7 18.5 Z" />
</svg>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.