Media

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Play, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** readyState floor for a legal seek — metadata is in, so `duration` is known. */
const HAVE_METADATA = 1
/** Frames closer than this to the one already on screen are not worth another seek. */
const MIN_SEEK_DELTA = 1 / 60
/** Seeking exactly to `duration` fires `ended` and parks the element on a blank frame. */
const TAIL_GUARD = 1 / 24
/** A backgrounded tab resumes with a huge gap — clamp it so the easing never leaps. */
const MAX_DT = 1 / 30

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/video-scrub-scroll.json

Prompt

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: sticky anchors to, not against window. 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.seeking is true and the seeked event 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 currentTime before 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.

On This Page