Scroll Scrubbed Video
A pinned section whose scroll distance drives the video playhead — rAF-coalesced seeks, readyState-guarded, collapsing to a poster and a play control when motion or seeking is off the table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/video-scrub-scroll.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VideoScrubScroll" component
(lucide-react for the play and warning icons; no other dependencies).
It is a video whose playhead is driven by scroll position instead of by
playback: the section pins, and the scroll distance through it maps linearly
onto currentTime.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: src: string (self-hosted, byte-range seekable mp4/webm — short and
small, this is not a feature-length player); poster: string (still frame,
and the whole picture in every fallback); label: string (text alternative
for the clip); scrollLength?: number (default 2, clamped to >= 0.25) — how
many stage-heights of scrolling the clip is stretched across; pin?: boolean
(default true); stageHeight?: string (default "100vh", any CSS length);
scrub?: boolean (default true); smoothing?: number (default 0.18, clamped
to 0-0.9); showProgress?: boolean (default true); stageClassName?: string.
className merges onto the root via cn(); children render as an overlay
inside the pinned frame.
- Two boxes, two class hooks: the ROOT is the invisible scroll track
(height = calc(stageHeight * (1 + scrollLength)) while pinned, plain
stageHeight otherwise) and takes className; the STAGE is the sticky frame
the viewer actually sees and takes stageClassName. Say this in the JSDoc —
consumers otherwise style the track and wonder why nothing moved.
- Derived mode, never stored twice:
mode = load error ? "error"
: (!scrub || prefers-reduced-motion || duration not finite) ? "static"
: "scrub".
Expose it as data-mode on the root so consumers can style off it.
- Publish the scroll ratio as a --vss-progress custom property (0-1) on the
root, written imperatively. That is the extension point: overlay bars,
chapter highlights and parallax layers read it with plain CSS calc() and
cost zero re-renders. Reset it to 0 whenever the mode stops being "scrub":
because it is written to the DOM, React's style diff sees no change in the
JSX value and would leave consumers reading a frozen ratio next to a poster
that is not playing.
Behavior
- Progress geometry is measured against the scrollport, not the window: walk
up from the root to the first ancestor whose computed overflow-y is neither
visible nor clip (stopping at body/documentElement), because that is the
box position: sticky anchors to. Resolve it ONCE per effect run and re-read
only its rect per frame — a getComputedStyle walk per scroll tick is the
cost this component exists to avoid. Fall back to the viewport.
With rel = sectionRect.top - scrollportTop:
pinned: ratio = clamp01(-rel / (sectionHeight - stageHeight))
unpinned: ratio = clamp01((portHeight - rel) / (portHeight + sectionHeight))
The unpinned mapping is the frame's own pass through the scrollport, so
pin={false} still scrubs — it just uses a window it did not have to reserve.
- One rAF per tick: scroll (passive, capture phase, so nested scrollers are
covered by a single listener), resize, a ResizeObserver on the root and the
video's own loadedmetadata / loadeddata / seeked events all call the same
schedule(), which no-ops when a frame is already pending. Never seek
directly from an event handler.
- Seek guards, in this order — each one is a real failure mode:
1. readyState < HAVE_METADATA (1) or a non-finite duration: return. The
assignment would be silently dropped; loadedmetadata restarts the loop.
2. video.seeking is true: return. Queueing seeks onto a decoder that is
still working is what turns scrubbing into stutter; the seeked event
schedules the next frame.
3. |target - currentTime| <= 1/60s: skip, the frame on screen is already
the right one.
Clamp the target to duration - 1/24s: seeking exactly to duration fires
ended and parks on a blank frame.
- Optional easing (smoothing): keep an eased "shown" ratio that chases the
scroll-derived target with a frame-rate independent step —
shown += (target - shown) * (1 - (1 - smoothing) ** (dt * 60)) — clamp dt to
1/30s so a backgrounded tab does not resume with a leap, snap to the target
under 0.0005, and re-schedule while it is still moving. Force smoothing to
0 on (pointer: coarse): touch decoders make a poor scrub target and every
eased frame is another seek to pay for.
- Idle when unseen: an IntersectionObserver cancels the pending frame when
the section leaves the viewport and reseeds the easing timebase when it
comes back, so a hero far below the fold costs nothing.
- The video is always muted (imperatively — React does not sync the muted
property reliably) and playsInline, and it NEVER plays in scrub mode; the
effect pauses it on entry in case a fallback playback was running.
preload="auto" while scrubbing (seeking needs data ahead of the playhead),
"metadata" otherwise.
- Fallback that still works: in "static" the track collapses to one stage
height, the poster shows, and a real play button starts ordinary playback
with native controls. Pressing play unmounts that button, so focus the
now-controlled video once play() resolves and announce that playback started
— otherwise the keyboard viewer who just pressed Enter is dropped back to the
top of the document with no confirmation. Leave the muted property alone once
the viewer has pressed play, so the controls' unmute button does what it
says. In "error" the poster is additionally painted as an <img> layer (a
media error does not keep the poster attribute visible in every browser)
under a quiet "Clip unavailable" badge, and the scroll loop tears itself down
as the error lands.
- Read prefers-reduced-motion and (pointer: coarse) through matchMedia with a
change listener (useSyncExternalStore, server snapshot false) so flipping
the OS setting mid-session switches modes live — and both listeners are
removed on unmount, along with the rAF, the scroll/resize listeners, the
video listeners and both observers.
- src changes reset failed/seekable/started through a render-phase state
adjustment (compare a prevSrc state value), so one 404 cannot poison a
recycled frame and a working clip never flashes the fallback.
Rendering & styling
- Root: relative w-full, height from the calc above, data-mode, --vss-progress.
Stage: relative isolate w-full overflow-hidden bg-muted, sticky top-0 only
while pinned (change to top-16 if the page has a fixed header).
- Video: absolute inset-0 size-full object-cover; aria-hidden in scrub mode
with a visually-hidden <span> carrying `label` as the text alternative
(scroll-driven frames are not consumable by assistive tech); aria-label in
the fallback modes, where it names a real player.
- Overlay children sit in an absolute inset-0 z-10 layer that is
pointer-events-none with [&_a]/[&_button] opting back in, so overlay copy
never swallows the native controls. A bg-gradient-to-t from-background/85
via-background/25 scrim renders only when there are children to keep legible.
- Progress rail: absolute bottom edge, h-1 bg-foreground/15 with a fill that
is transform: scaleX(ratio) written straight to the DOM — no re-render per
scroll — tinted with linear-gradient(90deg, var(--chart-1), var(--chart-2)).
- Play button: bg-primary / text-primary-foreground pill with
focus-visible:ring-2 ring-ring ring-offset-2, a hover AND focus-visible
scale so keyboard users get the same affordance, all neutralised under
motion-reduce.
- A polite live region (role="status", sr-only) carries the fallback reason and
the "playing, use the controls" confirmation, so the mode switch a sighted
user watches happen is announced too.
- Semantic tokens only: bg-muted / bg-background/85 / bg-primary /
text-primary-foreground / text-muted-foreground / border / ring /
var(--chart-1..2) — no hardcoded colors, dark mode for free.
Customization levers
- Pacing: scrollLength is the one knob that matters. 0.5-1 reads as a quick
flick-book reveal, 2 is a comfortable hero, 4+ is a slow cinematic dolly.
Pair a long window with a short clip, never the reverse.
- Feel: smoothing 0 pins the playhead to the scrollbar exactly (crispest, most
seeks); 0.1-0.25 hides decoder jitter; above 0.4 the clip visibly lags the
page. Set showProgress={false} for a bare frame.
- Framing: stageHeight "100svh" avoids the mobile URL-bar resize; "70vh" or a
fixed px value makes it a band rather than a takeover. Swap object-cover for
object-contain when the footage must not be cropped.
- Layering: pass chapter copy as children and drive it from
calc(var(--vss-progress) * 100%) — opacity, translate, a segmented chapter
bar. Add parallax by translating an inner layer by a fraction of the same
variable.
- Windowing: replace the linear mapping with a segmented one (quantise the
ratio to N chapters before multiplying by duration) for a stepped
storyboard, or ease it (ratio ** 2) to slow the clip down at the start.
- Source strategy: swap the single src for a <source> list (webm + mp4) — the
seek logic is unchanged. Encode with a ~1s keyframe interval; sparse
keyframes are the number one reason a scrub feels chunky.
- Chrome swap: replace the fallback play button with your own Button
component, or render the fallback as a full <video controls> immediately by
starting `started` at true.Concepts
- Scroll position as playhead — the page is the transport. Distance scrolled through the section maps linearly onto
currentTime, which is why scrolling back up plays the clip backwards and stopping halfway holds a frame; nothing about it is playback, so nothing about it needs autoplay permission. - The pin window — the section reserves
stageHeight * (1 + scrollLength)of page so the sticky frame has a runway to be pulled through. That reserved height is the scroll window; shortening it speeds the clip up without touching the file. In every fallback the reservation disappears, because a poster with no scrubbing must not leave a dead column behind. - Scrollport-relative geometry — progress is measured against the nearest scrollable ancestor, the same box
position: stickyanchors to, not againstwindow. That is what lets the section live inside a modal, a docs preview or any nested scroller and still map correctly. - rAF coalescing — scroll fires far faster than the screen refreshes. Every entry point (scroll, resize, ResizeObserver, media events) funnels into one
schedule()that no-ops while a frame is pending, so a burst of events costs exactly one geometry read and at most one seek. - Single seek in flight — the decoder, not the scrollbar, sets the ceiling. A seek is skipped while
video.seekingis true and theseekedevent resumes the loop, which turns "queue 60 seeks a second and stutter" into "always seek to the newest position the decoder can accept". - readyState as a precondition — assigning
currentTimebefore metadata lands is silently dropped, so the guard is not defensive politeness: without it the first frames of scrolling are simply lost and the clip appears stuck on the poster. - Motion consent without content loss — reduced motion, an unseekable source and a load error all land on the same visible answer: the poster, plus an ordinary play control and a polite announcement. The clip stays watchable when scrubbing is not.
Lyrics Sync
Karaoke-style synced lyrics — an injected playhead lights each line, word by word where word timings exist, keeps it on the anchor until you scroll away, and turns every line into a seek target.
Liquid Distortion Image
A picture that ripples where the pointer touches it — an SVG feTurbulence + feDisplacementMap twin, masked to a soft lens that follows the cursor and settles back when it leaves.