webdevpuneet.com

Scroll-Linked Audio Waveform Scrub — Free Reverse-Scrub Player Effect

Scroll-Linked Audio Waveform Scrub · Scroll · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<section class="as-intro"><h1>Scroll ↓</h1><p>Scrolling scrubs the playhead across the waveform — the reverse of a normal player.</p></section>
<section class="as-wrap" id="asWrap">
  <div class="as-panel">
    <div class="as-meta"><span class="as-track-name">Interview — Take 4</span><span class="as-time" id="asTime">0:00 / 3:24</span></div>
    <div class="as-scope">
      <div class="as-bars" id="asBars"></div>
      <div class="as-played" id="asPlayed"></div>
      <div class="as-playhead" id="asPlayhead"></div>
    </div>
    <div class="as-progress-track"><div class="as-progress-fill" id="asProgressFill"></div></div>
  </div>
</section>
<section class="as-outro"><p>Scroll back up — the playhead reverses exactly.</p></section>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Scroll drives playhead
Position is a direct function of scroll, not a timer.
Synthetic waveform
Deterministic sine-based bars need no audio asset.
Single progress value
One rect measurement drives four synced visuals.
Exact reversal
Recalculated fresh each frame — no easing lag.
rAF-gated scroll listener
One calculation per frame, no missed or doubled updates.
Sticky scrub panel
Pinned via CSS position: sticky, no JS pinning needed.
Played-bar highlighting
Bars behind the playhead visually mark as played.
Real audio ready
Swap the marker-only demo for audio.currentTime control.

About this UI Snippet

Scroll-Linked Audio Waveform Scrub — Scroll Position Drives the Playhead

Screenshot of the Scroll-Linked Audio Waveform Scrub snippet rendered live

Most audio players work one way: time advances, and the playhead moves to match it. This snippet inverts that relationship — there's no clock running at all. The playhead's horizontal position is a direct, continuous function of how far you've scrolled through the section, so scrolling *is* scrubbing. It's built with plain vanilla JavaScript reading getBoundingClientRect() on scroll, no GSAP or audio library required.

A visual waveform, not an audio file

The bars are drawn once on load from a small deterministic formula — a sum of sine waves plus a stable pseudo-random jitter seeded with a fixed number — so the shape looks like a real recording's amplitude but never needs an actual audio asset to fetch or decode. If you do want real playback, the same progress value can drive audio.currentTime = progress * audio.duration on an HTMLAudioElement instead of just moving a marker.

Scroll position as the single source of truth

A tall wrapper section (340vh) gives the page room to scroll while a sticky inner panel stays pinned in the viewport. On every scroll event (throttled to one calculation per animation frame with a requestAnimationFrame guard flag), the code measures the wrapper's getBoundingClientRect() and divides the distance already scrolled by the total scrollable distance, clamped to 0–1. That single progress number positions the playhead, sizes the "played" overlay, updates the displayed time label, and marks which bars are already "played" — all four effects derive from the same measurement, so they never drift out of sync with each other.

Fully reversible, no state to reset

Because the playhead position is recalculated fresh from the current scroll offset on every frame rather than accumulated or eased toward a target, scrolling back up moves it back exactly, instantly, with no springy catch-up and no need to detect "reverse" as a special case. Whatever fraction of the section is scrolled is exactly the fraction of the track that's "played."

Cheap enough for a plain scroll listener

The update touches only style.left, style.width, and textContent — no layout-triggering reads inside the loop beyond the one getBoundingClientRect() call — so a passive scroll listener gated by requestAnimationFrame keeps this smooth without needing IntersectionObserver or a scroll-linked animation library.

Customizing it

Swap the synthetic waveform for real amplitude data decoded from an actual audio file via the Web Audio API's AnalyserNode, wire the same progress value to audio.currentTime, or change the wrapper height to make the scrub longer or shorter relative to scroll distance. Pair it with a scroll-driven progress bar or a scroll SVG path draw for other position-linked reveals.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSAn intro, a sticky waveform panel, and an outro render — no dependencies.
  2. 2
    Scroll into the waveform sectionThe playhead moves across the bars in direct proportion to scroll.
  3. 3
    Scroll slowly, then fastThe playhead tracks scroll position exactly either way — it never eases.
  4. 4
    Scroll back upThe playhead and time label reverse precisely, with no reset needed.
  5. 5
    Wire real audio (optional)Use the same progress value to set audio.currentTime on an <audio> element.
  6. 6
    Adjust the scrub lengthChange .as-wrap's height to make the scroll distance longer or shorter.

Real-world uses

Common Use Cases

Podcast page previews
Let scrolling scrub a synthetic waveform playhead, with no clock running and no audio asset needed.
Music portfolio pages
Pair with scroll SVG path draw reveals so a track and an illustration both respond to scroll position.
Interactive articles
Scrub through archival audio alongside the text of an interactive article, with one rect measurement driving four synchronised visuals at once.
Sound design showcases
Visualise a track without autoplay, using deterministic sine-based bars so the waveform looks the same every visit.
Progress indicator companions
Complement a CSS scroll-driven progress bar, recalculating fresh each frame so reversal has no easing lag.
Related: Scroll Milestone Confetti
See the Scroll Milestone Confetti for a related scroll pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Not by default — the waveform is a visual stand-in drawn from a deterministic formula, so no audio file needs to be fetched or decoded. If you want real playback, use the same progress value calculated on scroll to set audio.currentTime = progress * audio.duration on an HTMLAudioElement; the playhead marker logic stays identical.

A normal player has a clock: time advances and the playhead follows. This snippet has no clock at all — scroll position is the only input, and the playhead's location is recalculated directly from it on every scroll event. Scrolling up moves the playhead back exactly, since there's no elapsed time to account for, only current scroll offset.

IntersectionObserver reports discrete threshold crossings, which is great for one-time reveals but too coarse for a continuous 0–1 scrub value. getBoundingClientRect gives an exact pixel position every time it is read, so dividing scrolled distance by scrollable distance produces a smooth, precise progress fraction suitable for driving the playhead every frame.

The scroll handler is gated by a requestAnimationFrame flag so the expensive work (one rect read, a few style writes) runs at most once per frame regardless of how many scroll events fire. The style writes touch only left, width, and textContent — no properties that trigger layout thrashing — so it stays smooth even on lower-end devices.

Render the markup, then in a mount effect attach the passive scroll and resize listeners and run the same rAF-gated update function, reading refs instead of getElementById. Return a cleanup that removes both listeners. Because the effect is purely a function of the current scroll position, no component state needs to track "is playing" or elapsed time.