25 CSS Play / Pause Buttons10 / 25
Equalizer Soundwave Animated Toggle
The 'now playing' equalizer, generated entirely in CSS: 28 bars driven by one keyframe, each offset by a negative animation-delay so the wave never marches in lockstep. Press play and the bars come alive; press pause and they settle to a flat line. Because the stagger is computed from a per-bar custom property, adding bars is a copy-paste — no extra CSS.
Published Updated
The code
<section class="pp-10" aria-label="Equalizer soundwave animated toggle demo">
<div class="pp-10__stage">
<div class="pp-10__rig">
<div class="pp-10__head">
<p class="pp-10__station">FREQUENCY 108.4</p>
<p class="pp-10__now">Deep Field — Live Session</p>
</div>
<div class="pp-10__wave" aria-hidden="true">
<span style="--pp-10-i:0"></span><span style="--pp-10-i:1"></span><span style="--pp-10-i:2"></span><span style="--pp-10-i:3"></span><span style="--pp-10-i:4"></span><span style="--pp-10-i:5"></span><span style="--pp-10-i:6"></span><span style="--pp-10-i:7"></span><span style="--pp-10-i:8"></span><span style="--pp-10-i:9"></span><span style="--pp-10-i:10"></span><span style="--pp-10-i:11"></span><span style="--pp-10-i:12"></span><span style="--pp-10-i:13"></span><span style="--pp-10-i:14"></span><span style="--pp-10-i:15"></span><span style="--pp-10-i:16"></span><span style="--pp-10-i:17"></span><span style="--pp-10-i:18"></span><span style="--pp-10-i:19"></span><span style="--pp-10-i:20"></span><span style="--pp-10-i:21"></span><span style="--pp-10-i:22"></span><span style="--pp-10-i:23"></span><span style="--pp-10-i:24"></span><span style="--pp-10-i:25"></span><span style="--pp-10-i:26"></span><span style="--pp-10-i:27"></span>
</div>
<input class="pp-10-chk" type="checkbox" id="pp-10-toggle" aria-label="Play or pause the live session">
<label class="pp-10__btn" for="pp-10-toggle">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path class="pp-10__p" d="M8.8 5.6v12.8L18.6 12z" fill="currentColor"/>
<path class="pp-10__q" d="M8.4 5.6h3.1v12.8H8.4zM12.9 5.6H16v12.8h-3.1z" fill="currentColor"/>
</svg>
</label>
</div>
<p class="pp-10__chip" aria-hidden="true">negative animation-delay stagger · scaleY on the compositor · one keyframe</p>
</div>
</section><section class="pp-10" aria-label="Equalizer soundwave animated toggle demo">
<div class="pp-10__stage">
<div class="pp-10__rig">
<div class="pp-10__head">
<p class="pp-10__station">FREQUENCY 108.4</p>
<p class="pp-10__now">Deep Field — Live Session</p>
</div>
<div class="pp-10__wave" aria-hidden="true">
<span style="--pp-10-i:0"></span><span style="--pp-10-i:1"></span><span style="--pp-10-i:2"></span><span style="--pp-10-i:3"></span><span style="--pp-10-i:4"></span><span style="--pp-10-i:5"></span><span style="--pp-10-i:6"></span><span style="--pp-10-i:7"></span><span style="--pp-10-i:8"></span><span style="--pp-10-i:9"></span><span style="--pp-10-i:10"></span><span style="--pp-10-i:11"></span><span style="--pp-10-i:12"></span><span style="--pp-10-i:13"></span><span style="--pp-10-i:14"></span><span style="--pp-10-i:15"></span><span style="--pp-10-i:16"></span><span style="--pp-10-i:17"></span><span style="--pp-10-i:18"></span><span style="--pp-10-i:19"></span><span style="--pp-10-i:20"></span><span style="--pp-10-i:21"></span><span style="--pp-10-i:22"></span><span style="--pp-10-i:23"></span><span style="--pp-10-i:24"></span><span style="--pp-10-i:25"></span><span style="--pp-10-i:26"></span><span style="--pp-10-i:27"></span>
</div>
<input class="pp-10-chk" type="checkbox" id="pp-10-toggle" aria-label="Play or pause the live session">
<label class="pp-10__btn" for="pp-10-toggle">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path class="pp-10__p" d="M8.8 5.6v12.8L18.6 12z" fill="currentColor"/>
<path class="pp-10__q" d="M8.4 5.6h3.1v12.8H8.4zM12.9 5.6H16v12.8h-3.1z" fill="currentColor"/>
</svg>
</label>
</div>
<p class="pp-10__chip" aria-hidden="true">negative animation-delay stagger · scaleY on the compositor · one keyframe</p>
</div>
</section>.pp-10,
.pp-10 *,
.pp-10 *::before,
.pp-10 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-10-bg);
--pp-10-bg: oklch(0.15 0.025 275);
--pp-10-card: oklch(0.2 0.03 275);
--pp-10-ink: oklch(0.97 0.006 270);
--pp-10-mut: oklch(0.68 0.03 275);
--pp-10-acc: oklch(0.78 0.17 330);
--pp-10-acc2: oklch(0.82 0.15 195);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-10-ink);
}
.pp-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 24px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(75% 55% at 50% 100%,oklch(0.3 0.1 330/.45),transparent 70%);
}
.pp-10__rig {
display: grid;
gap: 22px;
width: min(100%,540px);
padding: 28px 30px;
border-radius: 24px;
background: var(--pp-10-card);
box-shadow: 0 0 0 1px oklch(1 0 0/.07),0 28px 64px oklch(0 0 0/.5);
}
.pp-10__station {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .2em;
color: var(--pp-10-acc);
}
.pp-10__now {
font-size: 18px;
font-weight: 650;
letter-spacing: -.015em;
margin-top: 7px;
}
.pp-10__wave {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3px;
height: 92px;
padding: 0 2px;
}
.pp-10__wave span {
flex: 1;
height: 70%;
min-width: 3px;
border-radius: 99px;
background: linear-gradient(to top,var(--pp-10-acc),var(--pp-10-acc2));
scale: 1 .12;
transform-origin: center;
transition: scale .5s cubic-bezier(.3,0,.2,1);
animation: pp-10-wave 1.1s ease-in-out infinite alternate;
animation-delay: calc(var(--pp-10-i) * -80ms);
animation-play-state: paused;
}
.pp-10-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-10__btn {
justify-self: start;
display: grid;
place-items: center;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(140deg,var(--pp-10-acc),var(--pp-10-acc2));
color: oklch(0.16 0.03 300);
cursor: pointer;
box-shadow: 0 10px 30px oklch(0.5 0.2 330/.35);
transition: transform .24s cubic-bezier(.34,1.4,.5,1),box-shadow .3s;
}
.pp-10__btn:hover {
transform: scale(1.07);
}
.pp-10-chk:focus-visible+.pp-10__btn {
outline: 3px solid var(--pp-10-acc2);
outline-offset: 4px;
}
.pp-10__q {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__wave span {
animation-play-state: running;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__p {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__q {
display: block;
}
@keyframes pp-10-wave {
from {
scale: 1 .18;
}
to {
scale: 1 1;
}
}
.pp-10__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-10-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.12);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-10__wave span {
animation: none !important;
scale: 1 .35;
}
.pp-10 * {
transition-duration: .01ms !important;
}
}.pp-10,
.pp-10 *,
.pp-10 *::before,
.pp-10 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-10-bg);
--pp-10-bg: oklch(0.15 0.025 275);
--pp-10-card: oklch(0.2 0.03 275);
--pp-10-ink: oklch(0.97 0.006 270);
--pp-10-mut: oklch(0.68 0.03 275);
--pp-10-acc: oklch(0.78 0.17 330);
--pp-10-acc2: oklch(0.82 0.15 195);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-10-ink);
}
.pp-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 24px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(75% 55% at 50% 100%,oklch(0.3 0.1 330/.45),transparent 70%);
}
.pp-10__rig {
display: grid;
gap: 22px;
width: min(100%,540px);
padding: 28px 30px;
border-radius: 24px;
background: var(--pp-10-card);
box-shadow: 0 0 0 1px oklch(1 0 0/.07),0 28px 64px oklch(0 0 0/.5);
}
.pp-10__station {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .2em;
color: var(--pp-10-acc);
}
.pp-10__now {
font-size: 18px;
font-weight: 650;
letter-spacing: -.015em;
margin-top: 7px;
}
.pp-10__wave {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3px;
height: 92px;
padding: 0 2px;
}
.pp-10__wave span {
flex: 1;
height: 70%;
min-width: 3px;
border-radius: 99px;
background: linear-gradient(to top,var(--pp-10-acc),var(--pp-10-acc2));
scale: 1 .12;
transform-origin: center;
transition: scale .5s cubic-bezier(.3,0,.2,1);
animation: pp-10-wave 1.1s ease-in-out infinite alternate;
animation-delay: calc(var(--pp-10-i) * -80ms);
animation-play-state: paused;
}
.pp-10-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-10__btn {
justify-self: start;
display: grid;
place-items: center;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(140deg,var(--pp-10-acc),var(--pp-10-acc2));
color: oklch(0.16 0.03 300);
cursor: pointer;
box-shadow: 0 10px 30px oklch(0.5 0.2 330/.35);
transition: transform .24s cubic-bezier(.34,1.4,.5,1),box-shadow .3s;
}
.pp-10__btn:hover {
transform: scale(1.07);
}
.pp-10-chk:focus-visible+.pp-10__btn {
outline: 3px solid var(--pp-10-acc2);
outline-offset: 4px;
}
.pp-10__q {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__wave span {
animation-play-state: running;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__p {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__q {
display: block;
}
@keyframes pp-10-wave {
from {
scale: 1 .18;
}
to {
scale: 1 1;
}
}
.pp-10__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-10-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.12);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-10__wave span {
animation: none !important;
scale: 1 .35;
}
.pp-10 * {
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: Equalizer Soundwave Animated Toggle
Source: https://codefronts.com/components/css-play-pause-buttons/equaliser-bars/
The 'now playing' equalizer, generated entirely in CSS: 28 bars driven by one keyframe, each offset by a negative animation-delay so the wave never marches in lockstep. Press play and the bars come alive; press pause and they settle to a flat line. Because the stagger is computed from a per-bar custom property, adding bars is a copy-paste — no extra CSS.
## HTML
```html
<section class="pp-10" aria-label="Equalizer soundwave animated toggle demo">
<div class="pp-10__stage">
<div class="pp-10__rig">
<div class="pp-10__head">
<p class="pp-10__station">FREQUENCY 108.4</p>
<p class="pp-10__now">Deep Field — Live Session</p>
</div>
<div class="pp-10__wave" aria-hidden="true">
<span style="--pp-10-i:0"></span><span style="--pp-10-i:1"></span><span style="--pp-10-i:2"></span><span style="--pp-10-i:3"></span><span style="--pp-10-i:4"></span><span style="--pp-10-i:5"></span><span style="--pp-10-i:6"></span><span style="--pp-10-i:7"></span><span style="--pp-10-i:8"></span><span style="--pp-10-i:9"></span><span style="--pp-10-i:10"></span><span style="--pp-10-i:11"></span><span style="--pp-10-i:12"></span><span style="--pp-10-i:13"></span><span style="--pp-10-i:14"></span><span style="--pp-10-i:15"></span><span style="--pp-10-i:16"></span><span style="--pp-10-i:17"></span><span style="--pp-10-i:18"></span><span style="--pp-10-i:19"></span><span style="--pp-10-i:20"></span><span style="--pp-10-i:21"></span><span style="--pp-10-i:22"></span><span style="--pp-10-i:23"></span><span style="--pp-10-i:24"></span><span style="--pp-10-i:25"></span><span style="--pp-10-i:26"></span><span style="--pp-10-i:27"></span>
</div>
<input class="pp-10-chk" type="checkbox" id="pp-10-toggle" aria-label="Play or pause the live session">
<label class="pp-10__btn" for="pp-10-toggle">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path class="pp-10__p" d="M8.8 5.6v12.8L18.6 12z" fill="currentColor"/>
<path class="pp-10__q" d="M8.4 5.6h3.1v12.8H8.4zM12.9 5.6H16v12.8h-3.1z" fill="currentColor"/>
</svg>
</label>
</div>
<p class="pp-10__chip" aria-hidden="true">negative animation-delay stagger · scaleY on the compositor · one keyframe</p>
</div>
</section>
```
## CSS
```css
.pp-10,
.pp-10 *,
.pp-10 *::before,
.pp-10 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-10-bg);
--pp-10-bg: oklch(0.15 0.025 275);
--pp-10-card: oklch(0.2 0.03 275);
--pp-10-ink: oklch(0.97 0.006 270);
--pp-10-mut: oklch(0.68 0.03 275);
--pp-10-acc: oklch(0.78 0.17 330);
--pp-10-acc2: oklch(0.82 0.15 195);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-10-ink);
}
.pp-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 24px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(75% 55% at 50% 100%,oklch(0.3 0.1 330/.45),transparent 70%);
}
.pp-10__rig {
display: grid;
gap: 22px;
width: min(100%,540px);
padding: 28px 30px;
border-radius: 24px;
background: var(--pp-10-card);
box-shadow: 0 0 0 1px oklch(1 0 0/.07),0 28px 64px oklch(0 0 0/.5);
}
.pp-10__station {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .2em;
color: var(--pp-10-acc);
}
.pp-10__now {
font-size: 18px;
font-weight: 650;
letter-spacing: -.015em;
margin-top: 7px;
}
.pp-10__wave {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3px;
height: 92px;
padding: 0 2px;
}
.pp-10__wave span {
flex: 1;
height: 70%;
min-width: 3px;
border-radius: 99px;
background: linear-gradient(to top,var(--pp-10-acc),var(--pp-10-acc2));
scale: 1 .12;
transform-origin: center;
transition: scale .5s cubic-bezier(.3,0,.2,1);
animation: pp-10-wave 1.1s ease-in-out infinite alternate;
animation-delay: calc(var(--pp-10-i) * -80ms);
animation-play-state: paused;
}
.pp-10-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-10__btn {
justify-self: start;
display: grid;
place-items: center;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(140deg,var(--pp-10-acc),var(--pp-10-acc2));
color: oklch(0.16 0.03 300);
cursor: pointer;
box-shadow: 0 10px 30px oklch(0.5 0.2 330/.35);
transition: transform .24s cubic-bezier(.34,1.4,.5,1),box-shadow .3s;
}
.pp-10__btn:hover {
transform: scale(1.07);
}
.pp-10-chk:focus-visible+.pp-10__btn {
outline: 3px solid var(--pp-10-acc2);
outline-offset: 4px;
}
.pp-10__q {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__wave span {
animation-play-state: running;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__p {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__q {
display: block;
}
@keyframes pp-10-wave {
from {
scale: 1 .18;
}
to {
scale: 1 1;
}
}
.pp-10__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-10-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.12);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-10__wave span {
animation: none !important;
scale: 1 .35;
}
.pp-10 * {
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: Equalizer Soundwave Animated Toggle
Source: https://codefronts.com/components/css-play-pause-buttons/equaliser-bars/
The 'now playing' equalizer, generated entirely in CSS: 28 bars driven by one keyframe, each offset by a negative animation-delay so the wave never marches in lockstep. Press play and the bars come alive; press pause and they settle to a flat line. Because the stagger is computed from a per-bar custom property, adding bars is a copy-paste — no extra CSS.
## HTML
```html
<section class="pp-10" aria-label="Equalizer soundwave animated toggle demo">
<div class="pp-10__stage">
<div class="pp-10__rig">
<div class="pp-10__head">
<p class="pp-10__station">FREQUENCY 108.4</p>
<p class="pp-10__now">Deep Field — Live Session</p>
</div>
<div class="pp-10__wave" aria-hidden="true">
<span style="--pp-10-i:0"></span><span style="--pp-10-i:1"></span><span style="--pp-10-i:2"></span><span style="--pp-10-i:3"></span><span style="--pp-10-i:4"></span><span style="--pp-10-i:5"></span><span style="--pp-10-i:6"></span><span style="--pp-10-i:7"></span><span style="--pp-10-i:8"></span><span style="--pp-10-i:9"></span><span style="--pp-10-i:10"></span><span style="--pp-10-i:11"></span><span style="--pp-10-i:12"></span><span style="--pp-10-i:13"></span><span style="--pp-10-i:14"></span><span style="--pp-10-i:15"></span><span style="--pp-10-i:16"></span><span style="--pp-10-i:17"></span><span style="--pp-10-i:18"></span><span style="--pp-10-i:19"></span><span style="--pp-10-i:20"></span><span style="--pp-10-i:21"></span><span style="--pp-10-i:22"></span><span style="--pp-10-i:23"></span><span style="--pp-10-i:24"></span><span style="--pp-10-i:25"></span><span style="--pp-10-i:26"></span><span style="--pp-10-i:27"></span>
</div>
<input class="pp-10-chk" type="checkbox" id="pp-10-toggle" aria-label="Play or pause the live session">
<label class="pp-10__btn" for="pp-10-toggle">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path class="pp-10__p" d="M8.8 5.6v12.8L18.6 12z" fill="currentColor"/>
<path class="pp-10__q" d="M8.4 5.6h3.1v12.8H8.4zM12.9 5.6H16v12.8h-3.1z" fill="currentColor"/>
</svg>
</label>
</div>
<p class="pp-10__chip" aria-hidden="true">negative animation-delay stagger · scaleY on the compositor · one keyframe</p>
</div>
</section>
```
## CSS
```css
.pp-10,
.pp-10 *,
.pp-10 *::before,
.pp-10 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-10-bg);
--pp-10-bg: oklch(0.15 0.025 275);
--pp-10-card: oklch(0.2 0.03 275);
--pp-10-ink: oklch(0.97 0.006 270);
--pp-10-mut: oklch(0.68 0.03 275);
--pp-10-acc: oklch(0.78 0.17 330);
--pp-10-acc2: oklch(0.82 0.15 195);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-10-ink);
}
.pp-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 24px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(75% 55% at 50% 100%,oklch(0.3 0.1 330/.45),transparent 70%);
}
.pp-10__rig {
display: grid;
gap: 22px;
width: min(100%,540px);
padding: 28px 30px;
border-radius: 24px;
background: var(--pp-10-card);
box-shadow: 0 0 0 1px oklch(1 0 0/.07),0 28px 64px oklch(0 0 0/.5);
}
.pp-10__station {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .2em;
color: var(--pp-10-acc);
}
.pp-10__now {
font-size: 18px;
font-weight: 650;
letter-spacing: -.015em;
margin-top: 7px;
}
.pp-10__wave {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3px;
height: 92px;
padding: 0 2px;
}
.pp-10__wave span {
flex: 1;
height: 70%;
min-width: 3px;
border-radius: 99px;
background: linear-gradient(to top,var(--pp-10-acc),var(--pp-10-acc2));
scale: 1 .12;
transform-origin: center;
transition: scale .5s cubic-bezier(.3,0,.2,1);
animation: pp-10-wave 1.1s ease-in-out infinite alternate;
animation-delay: calc(var(--pp-10-i) * -80ms);
animation-play-state: paused;
}
.pp-10-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-10__btn {
justify-self: start;
display: grid;
place-items: center;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(140deg,var(--pp-10-acc),var(--pp-10-acc2));
color: oklch(0.16 0.03 300);
cursor: pointer;
box-shadow: 0 10px 30px oklch(0.5 0.2 330/.35);
transition: transform .24s cubic-bezier(.34,1.4,.5,1),box-shadow .3s;
}
.pp-10__btn:hover {
transform: scale(1.07);
}
.pp-10-chk:focus-visible+.pp-10__btn {
outline: 3px solid var(--pp-10-acc2);
outline-offset: 4px;
}
.pp-10__q {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__wave span {
animation-play-state: running;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__p {
display: none;
}
.pp-10__rig:has(.pp-10-chk:checked) .pp-10__q {
display: block;
}
@keyframes pp-10-wave {
from {
scale: 1 .18;
}
to {
scale: 1 1;
}
}
.pp-10__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-10-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.12);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-10__wave span {
animation: none !important;
scale: 1 .35;
}
.pp-10 * {
transition-duration: .01ms !important;
}
}
```How this works
One animation, N phases. Each bar carries an index as a custom property and derives its own delay from it:
.bar{ --pp-10-i:0;
animation:pp-10-wave 1.1s ease-in-out infinite alternate;
animation-delay:calc(var(--pp-10-i) * -80ms); /* NEGATIVE = start mid-cycle */
animation-play-state:paused;
transform-origin:center; }
.rig:has(:checked) .bar{ animation-play-state:running; }The negative delay is the whole trick: a delay of -80ms × i starts each bar already inside its cycle, so the row is instantly phase-shifted instead of easing in one by one. Combined with alternate, you get a travelling wave from a single 4-line keyframe.
@keyframes pp-10-wave{ from{ scale:1 .18 } to{ scale:1 1 } }Scaling on the Y axis (rather than animating height) keeps the whole visualiser on the compositor — 28 bars at 60fps with zero layout work. The paused state is not 'stopped mid-bounce': a transition on scale plus the flat default means the bars glide down to a resting line, which reads as intentional rather than frozen.
Make it yours
- Bar count: duplicate the <span> and bump --pp-10-i — the delay maths adapts automatically. 12 bars for a compact badge, 40+ for a hero visualiser.
- Frequency curve: give centre bars a taller max scale (
--pp-10-h) so the wave peaks in the middle like a real spectrum rather than a flat band. - Gradient bars:
background:linear-gradient(to top, var(--acc), var(--acc2))withbackground-attachment:fixedon the row so the gradient spans the whole visualiser, not each bar. - Real audio data: swap the keyframe for a Web Audio AnalyserNode writing --pp-10-h per bar in a rAF loop; the CSS stays identical.
Gotchas — read before shipping
- Animating height triggers layout on every frame for every bar. Use scaleY with transform-origin:bottom — same look, a fraction of the cost.
- Positive delays make the bars start in sequence (a 'ripple in'), which looks like a loading state, not audio. Use negative delays.
- alternate + ease-in-out is what gives the bounce weight; linear looks robotic and reveals the loop.
- Decorative visualisers must be aria-hidden — a screen reader reading 28 empty spans is noise. The state lives on the button.
- Don't leave the animation running while paused at opacity 0: it still burns compositor time. Pause it.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
CSS custom properties in calc() for animation-delay work everywhere modern (Chrome 49, Safari 10, Firefox 31). :has() gates the running state — swap for a sibling combinator to reach older Firefox.