25 CSS Play / Pause Buttons08 / 25
CSS Play Button Icon Morph Animation
True path morphing — the play triangle's vertices slide into the pause bars — using the CSS d property, which lets you transition an SVG path's geometry the same way you transition a color. No GreenSock, no Flubber, no JS. Browsers without d interpolation get a tasteful rotate-and-crossfade instead, gated behind an @supports query, so the component degrades instead of breaking.
Published Updated
The code
<section class="pp-08" aria-label="SVG path morph play pause icon demo">
<div class="pp-08__stage">
<div class="pp-08__wrap">
<input class="pp-08-chk" type="checkbox" id="pp-08-toggle" aria-label="Play or pause">
<label class="pp-08__btn" for="pp-08-toggle">
<svg class="pp-08__icon" viewBox="0 0 24 24" width="46" height="46" aria-hidden="true">
<path class="pp-08__morph" fill="currentColor"/>
<g class="pp-08__fallback">
<path class="pp-08__p" d="M8.5 5.5L18 12L8.5 18.5Z" fill="currentColor"/>
<path class="pp-08__q" d="M8 5.5h3v13H8zM13 5.5h3v13h-3z" fill="currentColor"/>
</g>
</svg>
</label>
<p class="pp-08__cap">Vertices travel<span>d:path() interpolation</span></p>
</div>
<p class="pp-08__chip" aria-hidden="true">CSS d property · matched vertex counts · @supports fallback</p>
</div>
</section><section class="pp-08" aria-label="SVG path morph play pause icon demo">
<div class="pp-08__stage">
<div class="pp-08__wrap">
<input class="pp-08-chk" type="checkbox" id="pp-08-toggle" aria-label="Play or pause">
<label class="pp-08__btn" for="pp-08-toggle">
<svg class="pp-08__icon" viewBox="0 0 24 24" width="46" height="46" aria-hidden="true">
<path class="pp-08__morph" fill="currentColor"/>
<g class="pp-08__fallback">
<path class="pp-08__p" d="M8.5 5.5L18 12L8.5 18.5Z" fill="currentColor"/>
<path class="pp-08__q" d="M8 5.5h3v13H8zM13 5.5h3v13h-3z" fill="currentColor"/>
</g>
</svg>
</label>
<p class="pp-08__cap">Vertices travel<span>d:path() interpolation</span></p>
</div>
<p class="pp-08__chip" aria-hidden="true">CSS d property · matched vertex counts · @supports fallback</p>
</div>
</section>.pp-08,
.pp-08 *,
.pp-08 *::before,
.pp-08 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-08 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-08-bg);
--pp-08-bg: oklch(0.98 0.005 95);
--pp-08-ink: oklch(0.22 0.02 95);
--pp-08-mut: oklch(0.55 0.02 95);
--pp-08-acc: oklch(0.63 0.19 32);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-08-ink);
}
.pp-08__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(70% 55% at 50% 0%,oklch(0.94 0.04 60/.7),transparent 70%);
}
.pp-08__wrap {
display: grid;
justify-items: center;
gap: 22px;
}
.pp-08-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-08__btn {
display: grid;
place-items: center;
width: 132px;
height: 132px;
border-radius: 38px;
background: var(--pp-08-ink);
color: var(--pp-08-bg);
cursor: pointer;
box-shadow: 0 18px 44px oklch(0.3 0.05 60/.25);
transition: border-radius .5s cubic-bezier(.65,0,.35,1),background .35s,transform .25s;
}
.pp-08__btn:hover {
transform: translateY(-3px);
}
.pp-08-chk:checked+.pp-08__btn {
border-radius: 50%;
background: var(--pp-08-acc);
}
.pp-08-chk:focus-visible+.pp-08__btn {
outline: 3px solid var(--pp-08-acc);
outline-offset: 5px;
}
.pp-08__morph {
d: path("M8 5.5L12 7.8L12 16.2L8 18.5Z M12 7.8L17.5 12L17.5 12L12 16.2Z");
transition: d .45s cubic-bezier(.65,0,.35,1);
}
.pp-08-chk:checked+.pp-08__btn .pp-08__morph {
d: path("M8 5.5L11 5.5L11 18.5L8 18.5Z M13 5.5L16 5.5L16 18.5L13 18.5Z");
}
.pp-08__fallback {
display: none;
}
@supports not (d: path("M0 0Z")) {
.pp-08__morph {
display: none;
}
.pp-08__fallback {
display: block;
}
.pp-08__p,
.pp-08__q {
transform-origin: 12px 12px;
transition: opacity .32s,rotate .42s cubic-bezier(.65,0,.35,1),scale .42s;
}
.pp-08__q {
opacity: 0;
rotate: -20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__p {
opacity: 0;
rotate: 20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__q {
opacity: 1;
rotate: 0deg;
scale: 1;
}
}
.pp-08__cap {
text-align: center;
font-size: 15px;
font-weight: 640;
}
.pp-08__cap span {
display: block;
margin-top: 4px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 400;
color: var(--pp-08-mut);
}
.pp-08__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-08-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.02 95/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-08 * {
transition-duration: .01ms !important;
}
}.pp-08,
.pp-08 *,
.pp-08 *::before,
.pp-08 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-08 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-08-bg);
--pp-08-bg: oklch(0.98 0.005 95);
--pp-08-ink: oklch(0.22 0.02 95);
--pp-08-mut: oklch(0.55 0.02 95);
--pp-08-acc: oklch(0.63 0.19 32);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-08-ink);
}
.pp-08__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(70% 55% at 50% 0%,oklch(0.94 0.04 60/.7),transparent 70%);
}
.pp-08__wrap {
display: grid;
justify-items: center;
gap: 22px;
}
.pp-08-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-08__btn {
display: grid;
place-items: center;
width: 132px;
height: 132px;
border-radius: 38px;
background: var(--pp-08-ink);
color: var(--pp-08-bg);
cursor: pointer;
box-shadow: 0 18px 44px oklch(0.3 0.05 60/.25);
transition: border-radius .5s cubic-bezier(.65,0,.35,1),background .35s,transform .25s;
}
.pp-08__btn:hover {
transform: translateY(-3px);
}
.pp-08-chk:checked+.pp-08__btn {
border-radius: 50%;
background: var(--pp-08-acc);
}
.pp-08-chk:focus-visible+.pp-08__btn {
outline: 3px solid var(--pp-08-acc);
outline-offset: 5px;
}
.pp-08__morph {
d: path("M8 5.5L12 7.8L12 16.2L8 18.5Z M12 7.8L17.5 12L17.5 12L12 16.2Z");
transition: d .45s cubic-bezier(.65,0,.35,1);
}
.pp-08-chk:checked+.pp-08__btn .pp-08__morph {
d: path("M8 5.5L11 5.5L11 18.5L8 18.5Z M13 5.5L16 5.5L16 18.5L13 18.5Z");
}
.pp-08__fallback {
display: none;
}
@supports not (d: path("M0 0Z")) {
.pp-08__morph {
display: none;
}
.pp-08__fallback {
display: block;
}
.pp-08__p,
.pp-08__q {
transform-origin: 12px 12px;
transition: opacity .32s,rotate .42s cubic-bezier(.65,0,.35,1),scale .42s;
}
.pp-08__q {
opacity: 0;
rotate: -20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__p {
opacity: 0;
rotate: 20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__q {
opacity: 1;
rotate: 0deg;
scale: 1;
}
}
.pp-08__cap {
text-align: center;
font-size: 15px;
font-weight: 640;
}
.pp-08__cap span {
display: block;
margin-top: 4px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 400;
color: var(--pp-08-mut);
}
.pp-08__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-08-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.02 95/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-08 * {
transition-duration: .01ms !important;
}
}Here's a working CSS Play / Pause Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Play Button Icon Morph Animation
Source: https://codefronts.com/components/css-play-pause-buttons/morph-triangle/
True path morphing — the play triangle's vertices slide into the pause bars — using the CSS d property, which lets you transition an SVG path's geometry the same way you transition a color. No GreenSock, no Flubber, no JS. Browsers without d interpolation get a tasteful rotate-and-crossfade instead, gated behind an @supports query, so the component degrades instead of breaking.
## HTML
```html
<section class="pp-08" aria-label="SVG path morph play pause icon demo">
<div class="pp-08__stage">
<div class="pp-08__wrap">
<input class="pp-08-chk" type="checkbox" id="pp-08-toggle" aria-label="Play or pause">
<label class="pp-08__btn" for="pp-08-toggle">
<svg class="pp-08__icon" viewBox="0 0 24 24" width="46" height="46" aria-hidden="true">
<path class="pp-08__morph" fill="currentColor"/>
<g class="pp-08__fallback">
<path class="pp-08__p" d="M8.5 5.5L18 12L8.5 18.5Z" fill="currentColor"/>
<path class="pp-08__q" d="M8 5.5h3v13H8zM13 5.5h3v13h-3z" fill="currentColor"/>
</g>
</svg>
</label>
<p class="pp-08__cap">Vertices travel<span>d:path() interpolation</span></p>
</div>
<p class="pp-08__chip" aria-hidden="true">CSS d property · matched vertex counts · @supports fallback</p>
</div>
</section>
```
## CSS
```css
.pp-08,
.pp-08 *,
.pp-08 *::before,
.pp-08 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-08 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-08-bg);
--pp-08-bg: oklch(0.98 0.005 95);
--pp-08-ink: oklch(0.22 0.02 95);
--pp-08-mut: oklch(0.55 0.02 95);
--pp-08-acc: oklch(0.63 0.19 32);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-08-ink);
}
.pp-08__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(70% 55% at 50% 0%,oklch(0.94 0.04 60/.7),transparent 70%);
}
.pp-08__wrap {
display: grid;
justify-items: center;
gap: 22px;
}
.pp-08-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-08__btn {
display: grid;
place-items: center;
width: 132px;
height: 132px;
border-radius: 38px;
background: var(--pp-08-ink);
color: var(--pp-08-bg);
cursor: pointer;
box-shadow: 0 18px 44px oklch(0.3 0.05 60/.25);
transition: border-radius .5s cubic-bezier(.65,0,.35,1),background .35s,transform .25s;
}
.pp-08__btn:hover {
transform: translateY(-3px);
}
.pp-08-chk:checked+.pp-08__btn {
border-radius: 50%;
background: var(--pp-08-acc);
}
.pp-08-chk:focus-visible+.pp-08__btn {
outline: 3px solid var(--pp-08-acc);
outline-offset: 5px;
}
.pp-08__morph {
d: path("M8 5.5L12 7.8L12 16.2L8 18.5Z M12 7.8L17.5 12L17.5 12L12 16.2Z");
transition: d .45s cubic-bezier(.65,0,.35,1);
}
.pp-08-chk:checked+.pp-08__btn .pp-08__morph {
d: path("M8 5.5L11 5.5L11 18.5L8 18.5Z M13 5.5L16 5.5L16 18.5L13 18.5Z");
}
.pp-08__fallback {
display: none;
}
@supports not (d: path("M0 0Z")) {
.pp-08__morph {
display: none;
}
.pp-08__fallback {
display: block;
}
.pp-08__p,
.pp-08__q {
transform-origin: 12px 12px;
transition: opacity .32s,rotate .42s cubic-bezier(.65,0,.35,1),scale .42s;
}
.pp-08__q {
opacity: 0;
rotate: -20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__p {
opacity: 0;
rotate: 20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__q {
opacity: 1;
rotate: 0deg;
scale: 1;
}
}
.pp-08__cap {
text-align: center;
font-size: 15px;
font-weight: 640;
}
.pp-08__cap span {
display: block;
margin-top: 4px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 400;
color: var(--pp-08-mut);
}
.pp-08__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-08-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.02 95/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-08 * {
transition-duration: .01ms !important;
}
}
```Here's a working CSS Play / Pause Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Play Button Icon Morph Animation
Source: https://codefronts.com/components/css-play-pause-buttons/morph-triangle/
True path morphing — the play triangle's vertices slide into the pause bars — using the CSS d property, which lets you transition an SVG path's geometry the same way you transition a color. No GreenSock, no Flubber, no JS. Browsers without d interpolation get a tasteful rotate-and-crossfade instead, gated behind an @supports query, so the component degrades instead of breaking.
## HTML
```html
<section class="pp-08" aria-label="SVG path morph play pause icon demo">
<div class="pp-08__stage">
<div class="pp-08__wrap">
<input class="pp-08-chk" type="checkbox" id="pp-08-toggle" aria-label="Play or pause">
<label class="pp-08__btn" for="pp-08-toggle">
<svg class="pp-08__icon" viewBox="0 0 24 24" width="46" height="46" aria-hidden="true">
<path class="pp-08__morph" fill="currentColor"/>
<g class="pp-08__fallback">
<path class="pp-08__p" d="M8.5 5.5L18 12L8.5 18.5Z" fill="currentColor"/>
<path class="pp-08__q" d="M8 5.5h3v13H8zM13 5.5h3v13h-3z" fill="currentColor"/>
</g>
</svg>
</label>
<p class="pp-08__cap">Vertices travel<span>d:path() interpolation</span></p>
</div>
<p class="pp-08__chip" aria-hidden="true">CSS d property · matched vertex counts · @supports fallback</p>
</div>
</section>
```
## CSS
```css
.pp-08,
.pp-08 *,
.pp-08 *::before,
.pp-08 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-08 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-08-bg);
--pp-08-bg: oklch(0.98 0.005 95);
--pp-08-ink: oklch(0.22 0.02 95);
--pp-08-mut: oklch(0.55 0.02 95);
--pp-08-acc: oklch(0.63 0.19 32);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-08-ink);
}
.pp-08__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(70% 55% at 50% 0%,oklch(0.94 0.04 60/.7),transparent 70%);
}
.pp-08__wrap {
display: grid;
justify-items: center;
gap: 22px;
}
.pp-08-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-08__btn {
display: grid;
place-items: center;
width: 132px;
height: 132px;
border-radius: 38px;
background: var(--pp-08-ink);
color: var(--pp-08-bg);
cursor: pointer;
box-shadow: 0 18px 44px oklch(0.3 0.05 60/.25);
transition: border-radius .5s cubic-bezier(.65,0,.35,1),background .35s,transform .25s;
}
.pp-08__btn:hover {
transform: translateY(-3px);
}
.pp-08-chk:checked+.pp-08__btn {
border-radius: 50%;
background: var(--pp-08-acc);
}
.pp-08-chk:focus-visible+.pp-08__btn {
outline: 3px solid var(--pp-08-acc);
outline-offset: 5px;
}
.pp-08__morph {
d: path("M8 5.5L12 7.8L12 16.2L8 18.5Z M12 7.8L17.5 12L17.5 12L12 16.2Z");
transition: d .45s cubic-bezier(.65,0,.35,1);
}
.pp-08-chk:checked+.pp-08__btn .pp-08__morph {
d: path("M8 5.5L11 5.5L11 18.5L8 18.5Z M13 5.5L16 5.5L16 18.5L13 18.5Z");
}
.pp-08__fallback {
display: none;
}
@supports not (d: path("M0 0Z")) {
.pp-08__morph {
display: none;
}
.pp-08__fallback {
display: block;
}
.pp-08__p,
.pp-08__q {
transform-origin: 12px 12px;
transition: opacity .32s,rotate .42s cubic-bezier(.65,0,.35,1),scale .42s;
}
.pp-08__q {
opacity: 0;
rotate: -20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__p {
opacity: 0;
rotate: 20deg;
scale: .8;
}
.pp-08-chk:checked+.pp-08__btn .pp-08__q {
opacity: 1;
rotate: 0deg;
scale: 1;
}
}
.pp-08__cap {
text-align: center;
font-size: 15px;
font-weight: 640;
}
.pp-08__cap span {
display: block;
margin-top: 4px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 400;
color: var(--pp-08-mut);
}
.pp-08__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-08-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.02 95/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-08 * {
transition-duration: .01ms !important;
}
}
```How this works
d is a real CSS property on SVG <path> elements, and it interpolates — provided both paths have the same number of commands, of the same type, in the same order:
.icon path{
/* play: two sub-paths = left trapezoid + right wedge */
d:path("M8 5.5L12 7.8L12 16.2L8 18.5Z M12 7.8L17.5 12L17.5 12L12 16.2Z");
transition:d .45s cubic-bezier(.65,0,.35,1);
}
:checked ~ .btn .icon path{
/* pause: the same 2 × 4 points, squared off */
d:path("M8 5.5L11 5.5L11 18.5L8 18.5Z M13 5.5L16 5.5L16 18.5L13 18.5Z");
}Look at the wedge: L17.5 12 L17.5 12 repeats the apex so the triangle has four points, exactly like the rectangle it becomes. That vertex-count matching is the entire craft of shape morphing — libraries like Flubber exist only to do this padding automatically for arbitrary shapes.
The fallback is honest engineering, not an afterthought: @supports not (d:path("M0 0Z")) hides the morph path and shows two stacked icons that crossfade with a 20° counter-rotation. Users on older engines still see a smooth, intentional transition — they just don't see vertices travel.
Make it yours
- Ease for personality:
cubic-bezier(.68,-0.5,.27,1.5)makes the bars overshoot slightly as they square up — playful; linear reads mechanical/technical. - Morph to a stop square instead: target
d:path("M8 6L16 6L16 18L8 18Z M8 6L16 6L16 18L8 18Z")for a record/stop control. - Stroke morph: give the path
fill:none; stroke:currentColor; stroke-linejoin:roundand it morphs as line art (pairs well with demo 16). - Animate along a rotation: add
rotateto the SVG itself for a 90° spin during the morph — the two motions read as one gesture.
Gotchas — read before shipping
- Path strings must match command-for-command. M/L/Z vs M/C/Z will not interpolate; the browser snaps to the end value with no error message.
- Firefox does not yet animate the d property (it parses it, but interpolation is Chromium/WebKit) — the @supports fallback is mandatory, not optional.
- d:path() only works on <path>; <polygon>/<rect> geometry is not CSS-animatable. Convert shapes to paths first.
- Sub-path order matters: swapping which sub-path is first makes the left bar fly across to become the right bar.
- Keep the viewBox fixed. Morphing the path while the viewBox changes produces impossible-to-debug drift.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
CSS d interpolation: Chrome/Edge 108+, Safari 17.4+. Firefox renders the @supports fallback (crossfade + rotate) — visually equivalent at speed.