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

Custom Audio Player CSS — 9 Computed UI Patterns

Among nine custom audio player css demos, one precomputes bar heights from sine waves, one decays angular velocity so the vinyl coasts, and one scrolls lyrics

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

This set groups nine takes on a custom audio player css layout: an elapsed-time value that redraws a seek handle and a remaining-time label, a bank of sine curves that fakes an equalizer without touching real audio, an angular-velocity value that spins an album disc and eases it to a stop, a scroll-position value that morphs a full player into a bottom-docked mini bar, a pointer-position value that reorders a playlist queue as you drag, a synthetic amplitude value that pulses a circular spectrum ring, three slider values that each bound one equalizer band's sway, a timeline value that keeps a lyric line centered as it scrolls, and one hero scene where a single progress value drives three of those layers at once. None of the nine touch a real audio file — every value below comes from arithmetic, not from <audio> playback or a Web Audio graph.

Demo

Custom Control Bar Drawn From a Playback Progress Value
Open in a new tab

01 · Custom Control Bar Drawn From a Playback Progress Value — Measured area 2.546% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

PROGRESS_UPDATE_MSseekTofmt

Everything pattern 01 needs is that one elapsedS value plus a seekTo function and an fmt helper for the mm:ss label — no <audio> element sits anywhere behind it. Patterns 02 through 09 follow later on this page, each one sitting right beside the code block it belongs to.

Anatomy of nine custom audio player css patterns — where the one layer of calculation runs

# Pattern Cumulative area Avg. intensity Frames moved
01 Progress-driven control bar 2.546% 154.8 13 of 23
02 Synthetic waveform bars 14.217% 163.9 23 of 23
03 Vinyl turntable spin 7.679% 67.9 23 of 23
04 Scroll-morphed mini bar 30.593% 35.9 23 of 23
05 Drag-reordered queue 29.946% 46.7 23 of 23
06 Circular spectrum ring 8.062% 101.9 23 of 23
07 Preset equalizer sliders 9.767% 177.3 23 of 23
08 Timeline-synced lyrics 1.891% 82.4 14 of 23
09 Player hero scene 39.284% 31.2 23 of 23

All nine call requestAnimationFrame directly — no CSS transition or @keyframes drives the moving parts. Each pattern keeps one small state value (an elapsed time, an angular velocity, a scroll offset, a drag order, a timeline position) and a render function reads that value back every frame to update a transform or opacity, or to make a canvas draw call.

Pattern 09 covers the most area (39.284%) because it is the only item where three separately computed layers — the turntable, the waveform bars, and the spectrum ring — share one coordinate space and move together. Pattern 08 sits at the other end, covering the least area (1.891%) and moving in only 14 of 23 frames, since a lyric highlight only changes when the active line index flips rather than on every tick. Pattern 01 moves in just 13 of 23 frames for a related reason: its PROGRESS_UPDATE_MS value only recomputes the seek handle every 200ms, so most captured frames land between two recomputes and show no change at all.

Progress and timeline values that only recompute on a step

Patterns 01 and 08 both gate their redraw behind a value rather than redrawing every tick. 01's render only moves the seek handle when now - state.lastRenderMs >= PROGRESS_UPDATE_MS, so seekTo and the click/keydown handlers still update state.elapsedS immediately, but the visible handle position waits for the next allowed tick. 08's activeIndexOf compares a simulated progressS against a fixed LINE_TIMES array and only moves the highlighted row — and the list's scroll offset with it — when that comparison returns a different index.

Angular and radius values eased frame by frame

Patterns 03, 06, 07, and 09 each ease a value toward a target instead of jumping to it. 03's step function multiplies velocity by SPIN_DECAY every frame once playback stops, so the disc's rotation slows on a curve rather than snapping still. 06 and 07 both run a synth function that layers two sine curves per bar or per ring point to fake an amplitude, and 07 additionally scales that sway by a BAND_RESPONSE_RANGE tied to a slider's live value. 09's bandValue reuses the same sine-layering approach for three named bands that separately drive the backdrop blobs, the album art's pulse, and the ring's radius.

Position values recomputed from scroll and pointer input

Patterns 04 and 05 recompute a layout value from a browser-native position rather than from time. 04's recompute reads scrollEl.scrollTop, subtracts a threshold, and divides by MORPH_RANGE_PX to get a 0-to-1 morph value that sets the mini bar's opacity, translate, and scale together. 05's onPointerMove compares a dragged row's dragY against every other row's slot position, and slotOf looks up the resulting order on every pointer move so the rows in between can shift out of the way before the drop lands.

Code per pattern

Each of the nine code blocks below is the actual calculation function pulled straight from that pattern's own index.html file, not a shortened stand-in written just for this page.

01Custom Control Bar Drawn From a Playback Progress Value

A custom audio control bar recomputes the seek handle position and remaining-time label every frame from an elapsed-time value counted since playback started. Clicking the track, stepping the handle with arrow keys, or pressing play all funnel through the same seekTo function so the displayed position never drifts from the value that owns it.

Custom Control Bar Drawn From a Playback Progress Value
Open in a new tab

01 · Custom Control Bar Drawn From a Playback Progress Value — Measured area 2.546% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

PROGRESS_UPDATE_MSseekTofmt
  function seekTo(elapsedS) {
    state.elapsedS = Math.min(TRACK_LENGTH_S, Math.max(0, elapsedS));
    if (state.playing) state.playStart = performance.now() - state.elapsedS * 1000;
    render(state.elapsedS);
  }

  function onTrackClick(e) {
    human();
    var r = track.getBoundingClientRect();
    var ratio = (e.clientX - r.left) / r.width;
    seekTo(ratio * TRACK_LENGTH_S);
  }

  function onSeekKeydown(e) {
    if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
      e.preventDefault();
      human();
      seekTo(state.elapsedS + (e.key === 'ArrowRight' ? SEEK_STEP_S : -SEEK_STEP_S));
    }
  }

  function onPlayClick() {
    human();
    setPlaying(!state.playing);
  }

02Equalizer Bars Driven by a Synthetic Waveform Value

Instead of real audio analysis, several sine curves are layered to precompute each bar height, then time-shifted while playing to look like an equalizer — unlike post 291's real-analysis bars, this item uses only decorative synthetic data. There is no AudioContext, no AnalyserNode, and no audio file anywhere in the demo; synth is plain trigonometry.

Equalizer Bars Driven by a Synthetic Waveform Value
Open in a new tab

02 · Equalizer Bars Driven by a Synthetic Waveform Value — Measured area 14.217% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

makeLcgsynthBAR_COUNT
  function synth(bar, tSec) {
    var s1 = Math.sin(tSec * bar.f1 * Math.PI * 2 + bar.p1);
    var s2 = Math.sin(tSec * bar.f2 * Math.PI * 2 + bar.p2) * bar.w2;
    var v = (s1 + s2) / (1 + bar.w2); // normalize to -1..1
    return 0.5 + v * 0.5; // 0..1
  }

  function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }

  var playing = true, t0 = 0;

  function render(now, playingNow) {
    var tSec = (now - t0) / 1000;
    for (var i = 0; i < BAR_COUNT; i++) {
      var bar = bars[i];
      var h = playingNow ? synth(bar, tSec) : bar.idle;
      // the bar's anchor is its bottom, so it only grows vertically from that origin -- not a center-based anchor
      bar.el.style.transform = 'scaleY(' + Math.max(0.06, h) + ')';
    }
  }

  function setPlaying(next) {
    playing = next;
    root.classList.toggle('is-playing', next);
  }

03Turntable Album Art Spun by an Angular-velocity Value

An angular-velocity value computed from playback state spins the album disc and tonearm each frame, and easing that value down on pause makes the disc coast to a stop like it has inertia. SPIN_DECAY is the one constant that decides how long that coast lasts.

Turntable Album Art Spun by an Angular-velocity Value
Open in a new tab

03 · Turntable Album Art Spun by an Angular-velocity Value — Measured area 7.679% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPIN_DECAYARM_ANGLE_STARTstep
  var ARM_ANGLE_START = -18; // outer groove (playback start position)
  var ARM_ANGLE_END = -34;   // inner groove (playback end position)

  var angle = 0, velocity = 0, progressS = 0, playing = true, demoPlaying = true, last = 0, driveStart = 0;

  function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }

  function setPlaying(next) {
    playing = next;
    root.classList.toggle('is-playing', next);
  }

  function step(effPlaying, dt) {
    if (effPlaying) {
      velocity += (TARGET_VELOCITY - velocity) * SPIN_UP_RATE;
      progressS = Math.min(PLAY_LENGTH_S, progressS + dt);
    } else {
      velocity *= SPIN_DECAY; // decays angular velocity every frame so it looks like the disc coasts to a stop
    }
    angle = (angle + velocity * dt) % 360;
    disc.style.transform = 'rotate(' + angle.toFixed(2) + 'deg)';
    var t = progressS / PLAY_LENGTH_S;
    var armAngle = ARM_ANGLE_START + (ARM_ANGLE_END - ARM_ANGLE_START) * t;
    arm.style.transform = 'rotate(' + armAngle.toFixed(2) + 'deg)';
  }

04Bottom-docked Mini Bar That Collapses From a Scroll Value

The transition point between the full player and a bottom-docked mini bar is computed from the scroll-position value, so crossing it shrinks the album art and controls into a small pill fixed at the bottom. Whether that scroll came from a real gesture or a keyboard step, the same recompute function decides the mini bar's opacity and scale.

Bottom-docked Mini Bar That Collapses From a Scroll Value
Open in a new tab

04 · Bottom-docked Mini Bar That Collapses From a Scroll Value — Measured area 30.593% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MORPH_RANGE_PXrecomputeprogrammatic
  function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }

  function recompute() {
    var threshold = full.offsetHeight; // computes the transition point from the actual card height
    var y = scrollEl.scrollTop;
    var p = Math.min(1, Math.max(0, (y - threshold) / MORPH_RANGE_PX));
    mini.style.opacity = String(p);
    mini.style.transform = 'translateY(' + ((1 - p) * 16) + 'px) scale(' + (0.94 + p * 0.06) + ')';
    mini.style.pointerEvents = p > 0.5 ? 'auto' : 'none';
  }

  function onScroll() {
    if (programmatic) { programmatic = false; recompute(); return; }
    human(); recompute();
  }

  function onScrollKeydown(e) {
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      human();
      scrollEl.scrollTop += (e.key === 'ArrowDown' ? SCROLL_STEP_PX : -SCROLL_STEP_PX);
      recompute();
    }
  }

05Queue Reorder That Recomputes an Order Value on Drag

Dragging a queue item compares its pointer y-position against every other item position value to recompute order, and once a slot is decided the items in between shift by their own position value to make room. SETTLE_RATE is a separate constant used elsewhere in the same file to lerp those in-between rows toward their new slot instead of snapping.

Queue Reorder That Recomputes an Order Value on Drag
Open in a new tab

05 · Queue Reorder That Recomputes an Order Value on Drag — Measured area 29.946% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SETTLE_RATEslotOfonPointerMove
  function slotOf(rowIndex) { return order.indexOf(rowIndex); }

  function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; rows[DEMO_ROW].classList.remove('is-dragging'); } }

  var dragIndex = -1, startClientY = 0, startSlotY = 0, dragY = 0;

  function onPointerDown(e, i) {
    human();
    dragIndex = i;
    startClientY = e.clientY;
    startSlotY = slotOf(i) * ROW_H;
    dragY = startSlotY;
    rows[i].classList.add('is-dragging');
    rows[i].setPointerCapture(e.pointerId);
  }
  function onPointerMove(e) {
    if (dragIndex < 0) return;
    dragY = startSlotY + (e.clientY - startClientY);
    var toSlot = Math.max(0, Math.min(rows.length - 1, Math.round(dragY / ROW_H)));
    var fromSlot = slotOf(dragIndex);
    if (toSlot !== fromSlot) {
      order.splice(fromSlot, 1);
      order.splice(toSlot, 0, dragIndex);
    }
  }

06Circular Spectrum Ring That Grows From a Radius Value

Each dot ringing the album art gets its radius from a synthetic amplitude value, and while playing that radius ripples over time so the ring around the art pulses larger and smaller. Unlike post 291's AudioContext/AnalyserNode analysis, synth here is the same two-sine trick as pattern 02, just mapped onto a radius instead of a bar height.

Circular Spectrum Ring That Grows From a Radius Value
Open in a new tab

06 · Circular Spectrum Ring That Grows From a Radius Value — Measured area 8.062% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

makeLcgsynthRANGE_R
  var POINT_COUNT = 40;
  var INNER_R = 36; // outer radius of the album art -- where the ring starts
  var RANGE_R = 36; // max range the radius value can grow by -- variant: amplitude multiplier, raised from 20 so the ripple shows
  var rng = makeLcg(4813);
  var points = [];
  for (var i = 0; i < POINT_COUNT; i++) {
    points.push({
      // layers two sine curves with a different phase/frequency per point to synthesize a radius value -- not a value read from real sound
      // unlike post 291's AudioContext/AnalyserNode analysis, this is a decorative value made from sine synthesis alone.
      f1: 0.5 + rng() * 0.4,
      p1: rng() * Math.PI * 2,
      f2: 1.3 + rng() * 0.7,
      p2: rng() * Math.PI * 2,
      w2: 0.2 + rng() * 0.25,
      idle: 0.08 + rng() * 0.06
    });
  }

  function synth(pt, tSec) {
    var s1 = Math.sin(tSec * pt.f1 * Math.PI * 2 + pt.p1);
    var s2 = Math.sin(tSec * pt.f2 * Math.PI * 2 + pt.p2) * pt.w2;
    var v = (s1 + s2) / (1 + pt.w2); // -1..1
    return 0.5 + v * 0.5; // 0..1
  }

07Preset Equalizer Whose Slider Value Sets the Bar Response Range

Three slider values for bass, mid, and treble each set the maximum sway range for their bar group, so moving a slider makes only that group's bars swing within the newly computed range. A customize html5 audio player css codepen is handy for a quick preview of that kind of interaction, but here every line of the response-range math sits in the block below and in the zip, no separate embed needed.

Preset Equalizer Whose Slider Value Sets the Bar Response Range
Open in a new tab

07 · Preset Equalizer Whose Slider Value Sets the Bar Response Range — Measured area 9.767% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAND_RESPONSE_RANGEsynthmakeLcg
  function render(now) {
    var tSec = (now - t0) / 1000;
    var effPlaying = isDemo ? true : playing;
    for (var b = 0; b < BAND_COUNT; b++) {
      var norm = Number(sliders[b].value) / 100; // 0..1
      var amp = norm * BAND_RESPONSE_RANGE;
      var bars = bands[b];
      for (var i = 0; i < bars.length; i++) {
        var bar = bars[i];
        var sway = effPlaying ? synth(bar, tSec) : 0;
        var h = bar.base + sway * amp * 0.5;
        h = Math.max(0.06, Math.min(1, h));
        // the bar's anchor is its bottom, so it only grows vertically from that origin -- not a center-based anchor
        bar.el.style.transform = 'scaleY(' + h + ')';
      }
    }
  }

  function setPlaying(next) {
    playing = next;
    root.classList.toggle('is-playing', next);
  }

08Lyrics Scroll Whose Highlight Moves From a Timeline Value

The current highlighted lyric line is found by comparing a simulated playback-timeline value against each line's preset start time, and the list's scroll offset is recomputed alongside it so that line stays centered. Clicking a line or pressing arrow keys calls seekToLine, which rewrites the same timeline value the render loop reads.

Lyrics Scroll Whose Highlight Moves From a Timeline Value
Open in a new tab

08 · Lyrics Scroll Whose Highlight Moves From a Timeline Value — Measured area 1.891% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

activeIndexOfseekToLineLINE_TIMES
  function seekToLine(i) {
    i = Math.max(0, Math.min(LINE_COUNT - 1, i));
    playedMs = (LINE_TIMES[i] / SPEED) * 1000;
  }

  function render() {
    var progressS = (playedMs / 1000) * SPEED % PLAYBACK_LENGTH_S;
    var activeIndex = activeIndexOf(progressS);
    var target = activeIndex * LINE_H - (VIEWPORT_H / 2 - LINE_H / 2);
    var maxOffset = Math.max(0, LINE_COUNT * LINE_H - VIEWPORT_H);
    target = Math.max(0, Math.min(maxOffset, target));
    if (currentOffset == null) currentOffset = target;
    currentOffset += (target - currentOffset) * 0.22; // eases toward the spot the timeline moved to, with inertia
    list.style.transform = 'translateY(' + (-currentOffset).toFixed(2) + 'px)';
    for (var i = 0; i < lines.length; i++) {
      var dist = Math.abs(i - activeIndex);
      var isActive = i === activeIndex;
      lines[i].classList.toggle('is-active', isActive);
      lines[i].setAttribute('aria-selected', String(isActive));
      lines[i].style.opacity = String(isActive ? 1 : Math.max(0.28, 1 - dist * 0.22));
      lines[i].style.transform = 'scale(' + (isActive ? 1 : 0.94) + ')';
    }
  }

09Player Hero Scene Where a Blurred Backdrop and Ring Interlock

A color value sampled from the album art blurs into an oversized backdrop, and the turntable, waveform bars, and spectrum ring — three separately computed layers — share one coordinate space so a single playback-progress value drives all three into a hero scene. bandValue is the same sine-layering function as patterns 02 and 06, just called three times with three different band definitions.

Player Hero Scene Where a Blurred Backdrop and Ring Interlock
Open in a new tab

09 · Player Hero Scene Where a Blurred Backdrop and Ring Interlock — Measured area 39.284% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

bandValuedrawRingRING_POINTS
  function bandValue(def, tSec) {
    var s1 = Math.sin(tSec * def.f1 * Math.PI * 2 + def.p1);
    var s2 = Math.sin(tSec * def.f2 * Math.PI * 2 + def.p2) * def.w2;
    var v = (s1 + s2) / (1 + def.w2); // -1..1
    return 0.5 + v * 0.5; // 0..1
  }

  var RING_POINTS = 32;
  var ringPts = [];
  for (var i = 0; i < RING_POINTS; i++) {
    ringPts.push({ f: 2 + rng() * 1.4, p: rng() * Math.PI * 2, w: 0.35 + rng() * 0.3 });
  }

  var BAR_COUNT = 14;
  var bars = [];
  for (var i = 0; i < BAR_COUNT; i++) {
    var el = document.createElement('div');
    el.className = 'hero__bar';
    barsEl.appendChild(el);
    bars.push({ el: el, f: 3 + rng() * 1.6, p: rng() * Math.PI * 2, base: 0.18 + rng() * 0.1 });
  }

  function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }

Where values change the screen — progress, angular velocity, timeline

None of the nine patterns above read a real audio file. Progress, waveform height, angular velocity, and lyric timing are all computed values — sine synthesis for the waveform bars, the spectrum ring, and the equalizer sliders (02, 06, 07), a decaying multiplier for the turntable (03), and a plain counter compared against a fixed schedule for the progress bar and the lyric sync (01, 08). Styling audio player css this way, from a small state value plus a render function, is what keeps every one of the nine safe to drop next to a real <audio> element later without rewriting its layout.

Where the nine patterns break — visibility, decay, and keyboard reach

That last part is also where the pattern set breaks if you are not careful. The trap: because every value here is synthetic, wiring pattern 01's control bar to a real <audio> element means swapping state.elapsedS for the element's own currentTime and its timeupdate event — if you keep both running side by side, the fake elapsed-time value and the real playback time drift apart within seconds, and the seek handle silently stops matching what is actually playing. The same applies to pattern 08: LINE_TIMES has to be replaced with timestamps read from the real track, or the highlighted lyric will race ahead of or lag behind the audio the moment playback speed differs from SPEED even slightly.

None of the nine requestAnimationFrame loops checks anything before calling itself again — pattern 01's frame/driveTick pair, and the single loop inside each of the other eight files, all end with an unconditional call back into requestAnimationFrame and nothing that reads document.hidden or listens for a visibility change anywhere in the nine files. The only thing that ever stops that call from happening is the prefers-reduced-motion branch, and it does that by never starting the loop in the first place — a tab that loses and regains focus has no equivalent switch, so the same loop that goes quiet under reduced motion just keeps scheduling itself frame after frame once it has started.

Pattern 03's velocity *= SPIN_DECAY line has a related gap: multiplying the turntable's angular velocity by 0.965 every frame after playback stops shrinks it toward zero but never sets it to exactly zero, and nothing in the file snaps it flat once it drops below some small threshold. Left running long enough after pause, the disc is technically still turning by a fraction of a degree per frame — the variant row below that raises SPIN_DECAY toward 0.985 stretches that already-endless coast even further before it reads as stopped.

Pattern 05's row-level aria-label only names the pointer path — drag to reorder — even though onRowKeydown wires Enter or Space to grab a row and the arrow keys to move it once grabbed. Nothing on the row itself tells a keyboard or screen-reader user that second path exists; the reorder works either way, but only a sighted user who already knows to try the arrow keys after tabbing in would find it without the label saying so.

239r99kf sits inside the zip's own read-me note, right next to that same warning about wiring these nine patterns to a real <audio> element — worth reading before any of the nine gets near production audio.

Three variants and accessibility

Variant Changed value Feel
Adjust the playback-progress update-interval value 01 PROGRESS_UPDATE_MS 200ms → 80ms Shortening the update-interval value should make the seek handle recompute more often and look smoother.
Adjust the turntable inertia-decay value 03 SPIN_DECAY 0.965 → 0.985 Increasing the decay value should lengthen the time the disc takes to coast to a stop after pause.
Adjust the equalizer-slider response-range value 07 BAND_RESPONSE_RANGE 1.6 → 2.4 Increasing the response-range value should widen the height difference that group's bars swing through when the slider moves.

Every one of the nine reads prefers-reduced-motion once at load. When it is on, each pattern renders one static snapshot instead of looping, and only the click/keydown handlers that already existed for play, seek, drag, and slider control stay wired — nothing new is added for the reduced-motion branch, and nothing that was interactive before becomes keyboard-inaccessible after it.

Where can I find custom audio player css examples?

Most custom audio player css examples that turn up in a search either recolor the native <audio> element with a handful of ::-webkit-media-controls rules or hard-code a static waveform image behind a play button. The nine patterns above sit in a different spot: a small state value — an elapsed time, an angular velocity, a scroll offset, a drag order, a timeline position — computed once in plain JavaScript and read back every frame, with no <audio> element, no Web Audio graph, and no fetch call anywhere in the nine files. A music player ui css layout for a podcast app, a mini player, or an album release page can reuse the same click/keydown baseline and swap in whichever one of the nine values fits the layout.

FAQ

Does this custom audio player css set analyze any real sound?

No. All nine values — progress, waveform height, angular velocity, ring radius, and lyric timing — come from a counter, a decaying multiplier, or layered sine curves. None of the nine opens an AudioContext, reads an AnalyserNode, or fetches an audio file, unlike post 291's real-analysis visualizer.

Why does pattern 09 cover so much more area than pattern 08?

Pattern 09 shares one coordinate space across three separately computed layers — the turntable, the waveform bars, and the spectrum ring — so a single progress value moves all three every frame. Pattern 08 only swaps a highlighted lyric line and nudges a scroll offset when the active index changes, which touches far fewer pixels per frame.

Is this music player ui css set safe to wire to a real audio element?

The click/keydown listeners, the reduced-motion branch, and each calculation function transfer directly, but every value needs a real counterpart first — swap state.elapsedS for currentTime, LINE_TIMES for real timestamps, and drive the waveform or ring from an AnalyserNode if the visual needs to track actual sound rather than a decorative pattern.

Sources: MDN — CanvasRenderingContext2D · MDN — arc · MDN — requestAnimationFrame · MDN — Pointer events · MDN — prefers-reduced-motion

Related reading: Audio Visualizer JavaScript: 9 Parts, No Library · Drag to Reorder UI: 9 Lists, One Drop Index · Buffering Animation CSS — 9 Loading Patterns — the first drives its bars from a real AnalyserNode rather than the synthetic sine values used above, the second goes deeper into the same pointer-position reordering math that pattern 05 uses for its queue, and the third covers the loading-state animations a player like this one shows before a track is ready to play.

Enter the archive password

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