EqualizerBars
CSS equalizer indicator with phased bars.
Live demo
01Four variants
equalizer
Smooth sine β classic music meter.
spectrum
Peak-biased FFT shape.
pulse
Binary high/low β heartbeat cadence.
heartbeat
Sparse double-spike with long tail.
Live status badges
Inactive β seeded silhouette
seed = 1
seed = 42
seed = 1337
Implementation
02<script>
import EqualizerBars from '$lib/components/EqualizerBars.svelte';
</script>
β
<EqualizerBars variant="equalizer" bars={16} height={96} color="#38bdf8" />EqualizerBars draws N vertical bars and animates each with a CSS keyframe at a phase-shifted negative animation-delay. The visible wave is an illusion built from N independent CSS clocks β no JS clock, no rAF. Variants swap the keyframe shape (sine, peak-biased, binary, double-spike). Inactive mode freezes the bars at deterministic seeded heights for SSR-stable empty states.
Logic explainer
03What Does It Do? (Plain English)
EqualizerBars is a row of vertical bars whose heights oscillate in a coordinated wave that looks like an audio spectrum analyser frozen in motion. It is a compact "this thing is alive / streaming / processing" indicator β useful next to live audio, voice transcripts, AI generation status lights, or anywhere a static "loadingβ¦" feels too quiet. Four visual variants give different rhythms (smooth sine, peak-biased FFT, binary high/low, sparse heartbeat double-spike), and an active prop freezes the bars at a deterministic seeded silhouette for "idle" states.
It is decorative only β no value semantics, no input, ignored by keyboard and pointer. The wrapper carries role="img" plus a configurable aria-label so screen readers can describe it as a single visual element rather than reading individual bars.
How It Works (Pseudo-Code)
on render:
read props: bars, variant, speed, color, active, height, seed, ariaLabel
derived:
safeBars = clampBars(bars) // [1, 64]
safeSpeed = clampSpeed(speed) // [0.25, 4]
safeHeight = clampHeight(height) // [16, 256] px
safeVariant= pickVariant(variant)
safeColor = (color === 'auto') ? 'currentColor' : color
heights = seededHeights(safeBars, seed) // deterministic [0.15, 1] per bar
baseDurationS = 1.2 / safeSpeed
staggerStepS = baseDurationS * 0.09
state:
runAnimation = true // SSR default, flipped by onMount
on mount:
runAnimation = active && !isReducedMotion()
effect:
if !active: runAnimation = false
render:
div.eq-wrapper role="img" aria-label
data-equalizerbars-variant={safeVariant}
data-equalizerbars-active={active && runAnimation}
style="--eq-color, --eq-height, --eq-duration, --eq-stagger"
for each height h, index i:
span.eq-bar.eq-running={active && runAnimation}
style="--eq-idx, --eq-static-h: {h * 100}%"
CSS variant blocks (one keyframe per variant):
.eq-wrapper[data-equalizerbars-variant='equalizer'] .eq-running {
animation: eq-osc-equalizer var(--eq-duration) ease-in-out infinite alternate;
animation-delay: calc(var(--eq-idx) * var(--eq-stagger) * -1);
}
... and similar for spectrum, pulse, heartbeatThe Core Concept: Phased Identical Animations Look Like A Coordinated Wave
The "wave" you see across the bars is a visual side-effect of a simpler trick: every bar runs the same CSS keyframe, but each starts at a different phase by way of a negative animation-delay.
bar 0: animation-delay = 0ms (starts at frame 0)
bar 1: animation-delay = -100ms (acts as if it started 100ms ago β already 100ms into the keyframe)
bar 2: animation-delay = -200ms (already 200ms in)
bar i: animation-delay = -i Γ stagger Γ 1000msCSS treats negative delays as "the animation has already been playing" β the browser jumps the bar straight to the keyframe value at that offset. Because every bar is on the same loop period but at a different phase, they read as a moving wave even though there is no shared timeline anywhere.
bar 0 phase 0% β
bar 1 phase 8% β
bar 2 phase 17% β
bar 3 phase 25% β
bar 4 phase 33% β
bar 5 phase 42% β
bar 6 phase 50% β
...Each variant's keyframe expresses a different waveform:
equalizerβ0% scaleY(0.18) β 50% scaleY(0.65) β 100% scaleY(1)withease-in-out alternate(so the bar bounces between low and high). Smooth sine-flavoured idle.spectrumβ0% / 35% / 65% / 100%keyframe with peak at 35% and dip at 65%. Looks like a real FFT readout where mid frequencies dominate.pulseβ0% / 50% / 100%withsteps(2, jump-none)easing β binary high/low, no in-between values. Looks digital.heartbeatβ0% / 8% / 16% / 24% / 32% / 100%curve with a quick double-spike then a long flat dwell. EKG-style.
The seeded RNG (seededHeights) is mulberry32-derived β small, fast, deterministic. Same seed always produces the same silhouette, important for SSR / hydration parity. The function is exported from <script module> so unit tests can verify exact values.
active = true active = false
βββββββββββββ β β β β β β β β β β β β β
ββββββββββββ (frozen at seeded heights;
wave moves left-to-right no animation)CSS Animation Strategy
One keyframe per variant, attached via attribute selector on the wrapper:
.eq-wrapper[data-equalizerbars-variant='equalizer'] .eq-running {
animation: eq-osc-equalizer var(--eq-duration, 1.2s) ease-in-out infinite alternate;
animation-delay: calc(var(--eq-idx) * var(--eq-stagger, 0.1s) * -1);
}
@keyframes eq-osc-equalizer {
0% { transform: scaleY(0.18); }
50% { transform: scaleY(0.65); }
100% { transform: scaleY(1); }
}Several deliberate choices:
scaleYfromtransform-origin: 50% 100%rather than animatingheight. Scale is GPU-composited; height triggers layout. The bars all anchor to the bottom and grow upward.infinite alternateso the bar bounces between the two endpoint keyframes without a snap-back stutter.- Per-bar
--eq-idxindex, multiplied into the stagger inline. This is what gives each bar its phase offset without the JS knowing about it.
Inactive bars switch off the .eq-running class. Their height comes from --eq-static-h (a percentage based on the seeded RNG) and no transform is applied β so they sit at deterministic heights without any animation.
.eq-bar:not(.eq-running) {
height: var(--eq-static-h, 50%);
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.eq-running {
animation: none !important;
transform: none;
height: var(--eq-static-h, 50%);
}
}The @media rule is the catch-all β even if the JS gate didn't run (SSR-only, or an exception in matchMedia), the user's preference still wins.
Performance
- Steady state: zero JS work. All animation is CSS keyframes running on the GPU compositor. The
<span>count isbars(max 64). - Mount cost: trivial. Compute
seededHeights(one mulberry32 step per bar β ~64 multiplications at the cap) and readmatchMediaonce. - Per render: derived values recompute when props change. The wrapper re-emits its CSS variables; CSS picks up the new duration / colour and the running animations adopt the new values without restart.
- No canvas, no Web Audio β this is a pretend equaliser, not a real one. Real audio reactivity would need an AudioContext + AnalyserNode wrapper component (out of scope here).
State Flow Diagram
ββββββββββββββββββββββββββββββ
β initial render (SSR) β
β runAnimation = true β β optimistic; matches client default
β bars render with anim β
ββββββββββββββββ¬ββββββββββββββ
β onMount
βΌ
ββββββββββββββββββββββββββββββ
β capability check β
β active && !reducedMotion? β
ββββββββ¬ββββββββββββββ¬ββββββββ
β yes β no
βΌ βΌ
ββββββββββββββββ ββββββββββββββββββββ
β ANIMATED β β STATIC β
β .eq-running β β no .eq-running β
β CSS waves β β seeded heights β
ββββββββ¬ββββββββ ββββββββ¬ββββββββββββ
β active prop flips false β
βΌ βΌ
STATIC ANIMATED (if active flips true)
prefers-reduced-motion: reduce β STATIC, locked by @media ruleProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
bars |
number |
12 |
Bar count. Floored, clamped to [1, 64]. |
variant |
'equalizer' | 'spectrum' | 'pulse' | 'heartbeat' |
'equalizer' |
Animation rhythm. Unknown β 'equalizer'. |
speed |
number |
1 |
Multiplier; higher = faster cycle. Clamped to [0.25, 4]. |
color |
string |
'auto' |
'auto' uses currentColor. Otherwise any CSS colour. |
active |
boolean |
true |
When false, bars freeze at seeded heights β the "idle" silhouette. |
height |
number |
48 |
Wrapper height in pixels. Clamped to [16, 256]. |
seed |
number |
1 |
Seed for the inactive silhouette. Same seed = same shape. |
ariaLabel |
string |
'Audio visualisation' |
Screen-reader description. |
class |
string |
'' |
Extra wrapper classes. |
Edge Cases
| Situation | Behaviour |
|---|---|
bars = 0 or non-finite |
clampBars returns the default 12. Never renders zero bars from a malformed prop. |
speed = 0 |
Clamped to 0.25 β bars still animate, just slowly. |
active flips at runtime |
Effect drops runAnimation to false and the .eq-running class is removed; bars snap to seeded heights without restart. |
prefers-reduced-motion: reduce |
Bars render at seeded heights; no animation. Stylesheet-level @media is the safety net. |
Same seed across instances |
Same silhouette. Use seed = Date.now() or a per-component constant to vary. |
Very large bars (e.g. 64) |
64 spans render fine. The gap: 3px between bars makes them increasingly thin in a fixed-width host. |
| Colour rapidly changes via prop | --eq-color updates inline; existing animations continue with the new colour. No flash, no restart. |
variant rapidly changes via prop |
The selector swaps; the new keyframe takes over from the bar's current phase. There is a slight jump because the keyframes have different shapes β acceptable for occasional toggling, jarring if animated continuously. |
Dependencies
- Svelte 5 β
$state,$derived,$effect,$props,onMount. <script module>exports βpickVariant,clampSpeed,clampBars,clampHeight,seededHeights,isValidVariant,isReducedMotion. All pure, testable without a DOM. The mulberry32-style RNG keeps SSR / client renders identical.- Zero external libraries β no audio library, no animation library. Pure CSS keyframes.
File Structure
src/lib/components/EqualizerBars.svelte # implementation
src/lib/components/EqualizerBars.md # this explainer
src/lib/components/EqualizerBars.test.ts # unit tests for exported helpers
src/routes/equalizerbars/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'equalizer' | 'spectrum' | 'pulse' | 'heartbeat' | 'equalizer' | Keyframe shape β not just a colour swap. |
bars | number | 12 | Bar count. Clamped to [1, 64]. |
height | number | 48 | Container height in pixels. |
color | string | 'auto' | Bar fill colour. 'auto' inherits via currentColor. |
speed | number | 1 | Cycle multiplier. Clamped to [0.25, 4]. |
active | boolean | true | When false, freezes the bars at seed-based heights. |
seed | number | 1 | Deterministic silhouette seed for the inactive state. |
ariaLabel | string | 'Audio visualisation' | Screen-reader label for the animated graphic. |