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

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

Share & Support

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to walk through why dividing the scrolled distance by the total scrollable distance from getBoundingClientRect produces a value that behaves identically whether the user is scrolling up or down — there's no separate "reverse" code path because the position is recalculated from scratch every time, not accumulated. It's also useful for extending this into a real audio scrubber: ask it to replace the synthetic sine-based waveform with actual decoded amplitude data from the Web Audio API's AnalyserNode, or to wire the computed progress value to audio.currentTime so scrolling genuinely seeks through a real track rather than just moving a marker over a static shape.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "scroll-linked audio waveform scrub" effect in plain HTML, CSS, and vanilla JavaScript (no libraries, no audio file required — a visual demo only).

Requirements:
- A tall wrapper section (e.g. 300vh+) containing a sticky inner panel (position: sticky) that stays pinned in the viewport while the wrapper scrolls past, so there's room to scroll through the effect.
- Inside the sticky panel, a waveform made of many thin bar elements with varying heights generated on load from a deterministic formula (e.g. a sum of sine waves plus a fixed-seed pseudo-random jitter) so it looks like a real audio waveform without needing an actual audio file.
- A vertical playhead marker whose horizontal position (as a percentage) is calculated directly from scroll progress: on scroll, measure the wrapper's getBoundingClientRect, compute (scrolled distance) / (total scrollable distance) clamped to 0–1, and set the playhead's left position to that percentage. This is the inverse of a normal audio player — scroll position drives the playhead, not elapsed time.
- Gate the scroll handler with a requestAnimationFrame flag so the position only recalculates once per frame no matter how many scroll events fire, and use a passive scroll listener.
- A time label (e.g. "1:12 / 3:24") that updates from the same progress value multiplied by a fixed total duration, and an overlay showing which portion of the waveform is "played" behind the playhead.
- Confirm scrolling back up reverses the playhead exactly, with no lag or spring-back, because the position is recomputed fresh from the current scroll offset every time rather than eased or accumulated.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

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 pages
Let scroll preview a transcript-synced waveform.
Music portfolios
Pair with scroll SVG path draw reveals.
Interactive articles
Scrub archival audio alongside scrollytelling.
Sound design showcases
Visualize a track without autoplaying audio.
Course players
Preview lecture audio position while scroll-reading notes.
Progress indicators
Complement a scroll progress bar.

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.