25 CSS Play / Pause Buttons10 / 25

Pure CSSMIT licensed

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

Live Demo
Try it

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>
.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;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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)) with background-attachment:fixed on 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

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

Search CodeFronts

Loading…