Audio Bars
A row of equaliser bars bounces at uneven rhythms to show that something is playing, and freezes when paused.
Create an animated equaliser in SVG and CSS with a play/pause toggle. Draw seven rounded rect bars of equal height in an SVG, with transform-box: fill-box and transform-origin bottom so they scale from the baseline. Animate each with scaleY between 0.2 and 1 using an alternate infinite keyframe with ease-in-out, giving every bar its own duration (between 0.45s and 0.9s) and a negative delay so the rhythm never lines up. A toggle button sets data-playing on the wrapper; when not playing, set animation-play-state: paused and ease the bars down to scaleY(0.2). The button uses aria-pressed and swaps its label between "Play" and "Pause". Pause off screen too. Under prefers-reduced-motion, show static bars at varied heights. The caption reads "Now playing · Low Tide".
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const BARS = [0.62, 0.48, 0.81, 0.55, 0.9, 0.45, 0.7]
export default function AudioBars({ labels }: { labels: Record<string, string> }) {
const [playing, setPlaying] = useState(true)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(([e]) => el.toggleAttribute('data-offscreen', !e.isIntersecting))
io.observe(el)
return () => io.disconnect()
}, [])
return (
<div ref={ref} className={s.player} data-playing={playing || undefined}>
<svg viewBox="0 0 76 40" className={s.bars} aria-hidden="true">
{BARS.map((d, i) => (
<rect
key={i}
x={i * 11}
y="0"
width="6"
height="40"
rx="3"
className={s.bar}
style={{ '--dur': `${d}s`, '--delay': `${-i * 0.23}s`, '--rest': 0.3 + (i % 3) * 0.25 } as React.CSSProperties}
/>
))}
</svg>
<span className={s.track}>{labels.track}</span>
<button type="button" className={s.btn} aria-pressed={playing} onClick={() => setPlaying(!playing)}>
{playing ? labels.pause : labels.play}
</button>
</div>
)
}
.player {
display: grid;
justify-items: center;
gap: 12px;
}
.bars { width: 96px; height: 52px; }
.bar {
fill: var(--stage-accent);
transform-box: fill-box;
transform-origin: bottom;
transform: scaleY(0.2);
transition: transform 400ms var(--ease-out);
}
.player[data-playing] .bar {
animation: bounce var(--dur) ease-in-out var(--delay) infinite alternate;
}
.player[data-offscreen] .bar { animation-play-state: paused; }
@keyframes bounce {
from { transform: scaleY(0.2); }
to { transform: scaleY(1); }
}
.track {
font: 500 var(--text-sm) / 1 var(--font-body);
color: var(--stage-muted);
}
.btn {
height: 32px;
padding: 0 14px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
cursor: pointer;
}
.btn:hover { border-color: var(--stage-accent); }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.player[data-playing] .bar { animation: none; transform: scaleY(var(--rest)); }
}