You Might Also Like
Scroll-Linked Audio Waveform Scrub — Free Reverse-Scrub Player Effect
Scroll-Linked Audio Waveform Scrub · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll-Linked Audio Waveform Scrub — Scroll Position Drives the Playhead

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