GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Audio Visualizer JavaScript: 9 Parts, No Library

An audio visualizer JavaScript project needs one thing from the browser: Web Audio's AnalyserNode, which turns live sound into numbers every frame — and

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is a build order for an audio visualizer css scene rather than a component catalog. Mapping one frequency band to one bar that grows up from the floor (01) is the whole skill; everything after it rearranges that one idea. 02 bends the same bars around a circle, 03 stops counting frequencies and draws the wave itself, 04 collapses the spectrum into a single decision — a beat — and 05 and 06 spend that decision on letters and on color. 07 gives up live analysis on purpose, because numbers computed once draw a mountain that never shifts while a list scrolls. 08 turns the source around, from playback to input, and takes on the permission dialog along with the refusal that can follow it; 09 ends by collapsing everything into one honest number in dB. Five tiles run on the ink stage, two on orange, one on paper for the list card, and one on yellow, where someone is being asked for permission. The AnalyserNode is the only API every part touches. The wider craft of turning numbers into pictures lives in SVG chart drawing, this creative pillar's sibling is the node graph connect UI, and the player chrome these tiles drop into is video player controls. Turn the same canvas toward a hand that draws and it becomes signature pad canvas drawing; give the particles in 04 gravity and friction and you have crossed into physics simulation.

01Bar spectrum

What you see

One bar per frequency band, each scaled with scaleY by how loud its band is; transform-origin sits on the bottom edge, so the bar rises from the floor instead of stretching at both ends. The left and right halves mirror each other — bar i and its twin read the same bin, so 32 bars cost only 16 of the analyser's 32 bins.

Bar spectrum
Open in a new tab

01 · Bar spectrum — Measured area 19.231% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleYtransform-origingetByteFrequencyData

Where to look in the code

A now-playing indicator or a podcast page header; the second-largest mover of the nine at 19.231% area, 23/23 moving frames.

// --h is the height (0~1) that JS writes in from the real sound. with no value, the static look is a row of short bars.
.bs__bar {
  height: 100%; border-radius: $r-xs;
  background: linear-gradient(180deg, $subject-mint 0%, $color 60%, #ff9d2f 100%);
  transform-origin: bottom center;
  transform: scaleY(var(--h, .08));
  transition: transform $duration $ease-out;
}

02Radial spectrum

How a circular audio visualizer javascript ring is drawn

The same bars go around a circle, one every 7.5 degrees, and the pivot moves with them: transform-origin drops to the circle's center, so scaleY pushes each bar outward from the ring instead of straight up the screen. All 48 bars share one keyframe, phase-shifted by −41 ms per index through a negative animation-delay.

Radial spectrum
Open in a new tab

02 · Radial spectrum — Measured area 3.751% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotatetransform-origin--i

How to read the code

A ring around album art or the center visual on a radio page — 3.751% area, 23/23 moving frames.

// each bar hangs at left 50% / bottom 50% so its lower end touches the circle's center. the origin is that touching point (50% 100%).
.rs__bars { position: absolute; inset: 0; }
.rs__bar {
  position: absolute; left: 50%; bottom: 50%; width: 4px; height: 24px; margin-left: -2px;
  border-radius: $r-xs;
  background: linear-gradient(180deg, $stage-yellow 0%, $color 100%);
  transform-origin: 50% 100%;
  transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(var(--h, .12));
  transition: transform $duration $ease-out;
}

03Waveform scope

What moves

getByteTimeDomainData reads the amplitude at this instant rather than a frequency spectrum, and the loop writes the same 60 x,y pairs straight into the points attribute of two stacked SVG polylines, a dim base line and the lit one above it. With no canvas anywhere, stroke color, width, and the mint glow all stay in CSS, and the preview sends a 56 px dash flowing along the static wave.

Waveform scope
Open in a new tab

03 · Waveform scope — Measured area 2.893% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

polylinegetByteTimeDomainDatastroke-dasharray

What to change

A live waveform on a recording screen, or the input display in a voice-memo app; 2.893% area, 23/23 moving frames.

  // time domain — this reads "the amplitude right now" as it is, not the frequency.
  analyser.getByteTimeDomainData(wave);
  var step = Math.floor(wave.length / N);
  var line = [];
  var peak = 0;
  for (var i = 0; i < N; i++) {
    var v = (wave[i * step] - 128) / 128;
    peak = Math.max(peak, Math.abs(v));
    line.push(((i / (N - 1)) * W).toFixed(1) + ',' + (MID - v * (H / 2 - 6)).toFixed(1));
  }
  var p = line.join(' ');
  baseLine.setAttribute('points', p);
  flowLine.setAttribute('points', p);

04Beat-reactive particles

What you see

A beat is the moment bass energy jumps above its own running average × 1.3, and only that moment fires — one of four rings bursts outward while the core stays put. A fixed threshold loses the beat when the track quiets down and double-counts when it slams; a ratio against the running average does neither, and a 220 ms minimum gap stops one kick from counting twice.

Beat-reactive particles
Open in a new tab

04 · Beat-reactive particles — Measured area 4.8% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleopacityrequestAnimationFrame

Where to look in the code

A hero background on an event page or a music release landing page; 4.8% area, 23/23 moving frames.

  // beat detection: the moment bass energy rises above the running average of the preceding window × 1.3.
  // a fixed threshold (say "energy over 0.5") misses beats entirely once the track quiets down and turns every moment into a beat when it gets loud —
  // only a ratio against the average keeps the beat through changes in dynamics. the 220ms minimum gap stops one kick from being counted twice.
  if (bass > bassAvg * 1.3 && bass > 0.14 && now - lastBeatAt > 220) {
    lastBeatAt = now;
    beats += 1;
    beatText.textContent = String(beats);
    fire(rings[ringTurn % RING_COUNT], 'bp__ring--hit');
    fire(core, 'bp__core--hit');
    ringTurn += 1;
  }
  bassAvg += (bass - bassAvg) * 0.12;

05Bass-bouncing letters

What happens on screen

The title's letters are split across low, mid, and high bands, so G and R ride the bass while D and C wait for the highs — each letter jumps in place, origin centered, with only translateY and scale moving. The mid and high bands get display gains of 1.5 and 2.2 because sawtooth harmonics thin out as 1/n up the spectrum; that corrects the meter, not the sound.

Bass-bouncing letters
Open in a new tab

05 · Bass-bouncing letters — Measured area 9.328% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYanimation-delayanimation-fill-mode

How to read the code

An album or single release page title, or a countdown header; 9.328% area, 20/23 moving frames.

.tb__l {
  display: inline-block; font-size: clamp(44px, 12vw, 60px); line-height: 1; color: $color;
  transform-origin: 50% 50%;
  transform: translateY(calc(-18px * var(--j, 0))) scale(calc(1 + var(--j, 0) * .16));
  transition: transform $duration $easing;
}

06Energy gradient background

What moves

Total energy becomes a single hue angle that slowly stains the whole panel, and @property is what makes it continuous — registered as an <angle> (Chrome 85, Safari 16.4, Firefox 128), --hue interpolates, so the gradient slides between colors instead of snapping. It is the largest mover of the nine at 54.718% area with 23/23 moving frames, and the safest for photosensitive readers: lightness stays locked inside a 13–20% band while hue alone rotates.

Energy gradient background
Open in a new tab

06 · Energy gradient background — Measured area 54.717% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertylinear-gradienthsl

What to change

A now-playing screen background in a streaming app, or a live stream page.

@property --hue {
  syntax: '<angle>';
  inherits: false;
  initial-value: 210deg;
}

@include stage($stage-ink);

.ge {
  position: relative; display: flex; flex-direction: column; align-items: center; gap: $sp-4;
  width: 100%; max-width: 360px;
  padding: $sp-4; border-radius: $r-card;
  // the gradient sits on this very panel, the one that uses --hue — it is registered with inherits: false,
  // so the element using the value has to hold that value itself (measured in 262: putting it on the parent does nothing).
  background: linear-gradient(135deg, hsl(var(--hue) 72% 20%) 0%, hsl(calc(var(--hue) + 48deg) 62% 13%) 100%);
  transition: --hue $duration $ease-out;
}

07Mini player peak bars

What you see

OfflineAudioContext renders the same four-chord loop once when the page opens; each of 40 columns stores its loudest sample, and clip-path colors the mountain only as far as the playhead has reached. Because the shape is an array of numbers rather than live analysis, the waveform never shifts while the list scrolls or the viewport resizes.

Mini player peak bars
Open in a new tab

07 · Mini player peak bars — Measured area 5.867% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

clip-patharia-valuenowgrid-auto-flow

Where to look in the code

Podcast episode lists and track preview cards; 5.867% area, 22/23 moving frames, and at an average intensity of 184.9 the most intense loop of the set.

offline.startRendering().then(function (buf) {
  var data = buf.getChannelData(0);
  var slice = Math.floor(data.length / COLS);
  for (var c = 0; c < COLS; c++) {
    var peak = 0;
    for (var k = c * slice; k < (c + 1) * slice; k++) {
      var v = Math.abs(data[k]);
      if (v > peak) { peak = v; }
    }
    peaks.push(Math.max(.14, peak));
  }
  for (var i = 0; i < COLS; i++) {
    var a = document.createElement('i');
    a.className = 'tp__bar';
    a.style.setProperty('--h', peaks[i].toFixed(3));
    baseBars.appendChild(a);
    var b = document.createElement('i');
    b.className = 'tp__bar';
    b.style.setProperty('--h', peaks[i].toFixed(3));
    litBars.appendChild(b);
  }
});

08Microphone input visualizer

What happens on screen

getUserMedia asks exactly once: allow it and your voice drives the 24-bar ring; deny it and a synthesized 220 Hz tone drives the same ring — refusal is a designed path rather than an error, so the screen never sits empty. Measured in a real run, spoken input drove the bars to 0.446 while the fallback tone drove them to 0.831, so the handover is visible, not theoretical.

Microphone input visualizer
Open in a new tab

08 · Microphone input visualizer — Measured area 2.504% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getUserMediaaria-livecreateOscillator

How to read the code

Recording start screens, or the mic check before joining a video call; 2.504% area, 23/23 moving frames.

  var p = (navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
    ? navigator.mediaDevices.getUserMedia({ audio: true })
    : Promise.reject(new Error('no mediaDevices'));
  p.then(function (stream) {
    micOn = true;
    var src = ctx.createMediaStreamSource(stream);
    src.connect(analyser); // never route it back to the speakers — that causes feedback howl
    statusEl.textContent = 'Mic is live — try speaking';
    running = true;
    timer = requestAnimationFrame(draw);
  }).catch(function () {
    // denial is a normal path — a synthesized tone picks up the same loop so the screen never goes empty.
    micOn = false;
    fallbackTone();
    statusEl.textContent = 'Permission denied — switched to a synthesized tone';
    running = true;
    timer = requestAnimationFrame(draw);
  });

09Level meter with peak hold

What moves

RMS — the square root of the mean square, an energy average rather than an amplitude average — lights the 12 segment LEDs from the bottom up, and a peak line snaps to the highest segment reached, holds 900 ms, then eases back down at 0.01 per frame. steps(1, end) cuts every readout swap, so the dB number changes in a single frame with nothing in between, and the is-snap class drops the transition for one frame so a new peak follows instantly.

Level meter with peak hold
Open in a new tab

09 · Level meter with peak hold — Measured area 2.922% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(1, end)grid-template-rowstransition

What to change

An input level in recording software, or a broadcast meter; 19/23 moving frames at 2.922% area.

  // RMS — the square root of the mean square. it is an average of energy rather than of amplitude, which is what the meter's scale expects.
  analyser.getByteTimeDomainData(wave);
  var sum = 0;
  for (var k = 0; k < wave.length; k++) {
    var v = (wave[k] - 128) / 128;
    sum += v * v;
  }
  var rms = Math.sqrt(sum / wave.length);
  var frac = Math.min(1, rms * 3.4); // ×3.4 calibrates the meter scale — it does not change playback volume
  root.style.setProperty('--fill', (frac * 100).toFixed(1) + '%');
  // peak hold — a higher moment lifts it instantly, it sticks for 900ms, then eases back down.
  var now = performance.now();
  if (frac >= peakFrac) {
    peakFrac = frac;
    peakHoldUntil = now + 900;
    peak.classList.add('is-snap');
  } else if (now > peakHoldUntil) {
    peakFrac = Math.max(frac, peakFrac - 0.01);
    peak.classList.remove('is-snap');
  }

Where it breaks — the trap

The trap that shaped every tile here is that an AnalyserNode only produces numbers while audio is actually running. The renderer that captures this site's still frames drives the animation clock itself through the Web Animations API, so anything drawn inside a requestAnimationFrame callback — which is how every real visualizer works — never lands in a captured frame; the capture sees silence. Each grid preview (the .is-demo class) is therefore a puppet on honest strings: a spectrum sequence computed ahead of time and played as CSS keyframes, while the play button strips the class and hands those same elements to live Web Audio. What you see before pressing play is a promise about shape; what you see after is the sound.

The second trap only turned up in headless testing. Item 08's fallback tone was perfectly audible and completely invisible: at fftSize 64 in a 48 kHz context the bins are 750 Hz wide, so a 220 Hz sine falls into bin 0 — the DC bin the ring never reads, since the bars map freq[band + 1] with band running 0 through 8. fftSize 256 narrows the bins to 187.5 Hz, the tone moves into bin 1, and the first bar wakes up. When a tone you can hear draws nothing, check where the bin edges fall before touching a single line of the drawing code.

The third is reading a live meter too literally. Measured during real playback, item 04 counted 5 beats in its first four seconds and fired 4 ring bursts, and item 09's meter sagged to −36 dB at 5.4% fill between beats — that is the music resting between kicks, not the meter dying; a meter that never sags is either lying or normalized. The same run caught item 07's progressbar at aria-valuenow 24 while its time label still read 0:00, because the position updates every frame and the label only ticks each second.

Text that rides a colored stage was measured per element, worst background included, and the weak spots were moved rather than darkened: item 05's 57.6 px title letters hold 5.5:1 as ink on the #ff4d1f stage, item 08's status sentence reads 7.72:1 as #453a21 on #ffd23f, item 09's dB readout 7.37:1 as ink on #ff805b, and item 07's play button 4.53:1 as white on #2f6df6 — each pair recorded with its background hex in the contrast sheet. Item 07's time readout was re-measured under the same rule: the current-time digits now use the darker #0a4fe8 (5.65:1) instead of body blue, and the colon separator sits at 72% ink (6.85:1). The zip below carries all nine parts — HTML, SCSS, and the driving JS, and not one media file. Passing a signal that arrived from outside, like the microphone in 08, on to the next job is what the n8n workflow examples wire up node by node, and 33t2w53s is the password that opens the archive.

Three variations

Name Changed value Feel
Finer bands 01 analyser.fftSize = 64 to 256 Each bar reads a 187.5 Hz slice instead of 750, so chord changes flicker through the low bars separately
Softer onsets 04 bassAvg * 1.3 to * 1.15 Half-whispered kicks still burst a ring; dense tracks may over-trigger
Longer hold 09 peakHoldUntil = now + 900 to + 1600 The peak line rides above the segments broadcast-style between beats

AI prompt

When vibe-coding one of these from scratch, pinning down the audio graph, the trigger, and the limits in one pass saves you the rewrite. This fragment produced the beat particles.

Build a beat-reactive particle field.
Audio: Web Audio only — a 70 Hz sine kick every 500 ms over three sawtooth pads (110, 164.81, 220 Hz); no audio file, AudioContext.resume() inside the click.
Trigger: a beat is bass energy above its own running average × 1.3, minimum 220 ms apart; each hit fires one of four rings of 8 dots, start angles offset 11.25 deg per ring.
Motion: dots rest at --rest: 52px and burst to --fly: 84px in 480 ms while fading; the 52px core (#211d26 with a 4px #ffd23f ring) scales to 1.18 and never leaves.
Limits: the beat counter is text with aria-live="polite"; under prefers-reduced-motion the rings stop and the halo stays.
Never: a fixed energy threshold, particle exit as the hero shot, or sound starting without a user gesture.

If particles fire on every frame, your threshold is a number where it should be a ratio — swap energy > 0.5 for energy > average * 1.3 and it calms down.

Accessibility (reduced-motion)

A visualizer is decoration over sound, so every part carries a text alternative a silent reader can use, and each one updates as the sound does: aria-live announces the loudest band in 01·02·05, the wave state in 03, the beat count in 04, the energy percent in 06, the permission state in 08, and the dB value in 09, while 07 reports the playhead position through aria-valuenow on a progressbar, spoken as a number. The measured contrast backs the words: band labels read 11.68:1 on the ink stage, with the value chips beside them at 10.78:1. Under prefers-reduced-motion: reduce every loop stops and leaves a readable still: 01 settles into one symmetric spectrum frame, tall at the edges and low in the center, 02 keeps a hill-shaped ring drawn from a sine of the index, 03 freezes the static wave with the dash removed, 04 leaves the halo resting around its core, 05 leaves the letters standing with the legend explaining which band each rides, 06 parks at its initial 210-degree blue, 07 keeps the mountain colored to 60% with the head pin parked at that colored edge, 08 keeps the ring and its invitation, 09 holds a 30% fill beside −12 dB. Photosensitivity stays under the WCAG 2.3.1 ceiling of three flashes per second: the wave previews peak three times per two seconds, item 04's four bursts per two seconds are decaying swells rather than blinks, and item 06 cannot invert brightness at all because only hue moves.

FAQ

Why does nothing react until I press play?

Two reasons stack. An AudioContext starts suspended and only a user gesture calling resume() unlocks it — the autoplay policy every browser enforces. And before audio runs, an AnalyserNode returns silence, a flat array of zeros, so there is genuinely nothing to draw; that is exactly why the previews run precomputed CSS loops instead of pretending to be live. Web Audio measures 96.41% global support on caniuse (September 2026), and old Safari needed the webkitAudioContext prefix, which the demos still accept. Where only the rhythm has to be suggested and no sound is involved, the short motions in CSS keyframe animations are enough.

Can I plug a real track into this web audio api visualizer?

Yes. Replace buildAudio()'s oscillators with ctx.createMediaElementSource() on an <audio> element, and everything downstream — analyser, loops, meters — is unchanged. The demos synthesize their four-chord loop (196, 246.94, 293.66, 392 Hz, rotating every four beats) precisely so the zip carries no media file; the microphone path in 08 needs a secure context, https or localhost, where getUserMedia measures 96.05% global support. One caveat: 07's pre-render leans on OfflineAudioContext, which Safari only shipped at 14.1.

Is a flashing visualizer a photosensitivity risk?

The threshold in WCAG 2.3.1 is three flashes per second, and the previews stay at three peaks per two seconds — half the ceiling. Item 06 is the only large-area effect and it cannot invert brightness: lightness is locked in a 13–20% band while hue alone rotates. Under prefers-reduced-motion every loop stops and leaves a still you can read a state from, so movement is never the only channel.

Enter the archive password

The password is inside this article. You will find it as you read.