Scroll-Linked Video Scrubber for React
Sticky video that scrubs forward/backward as you scroll. Uses a tall wrapper and position: sticky - no scroll hijacking.
Component
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface ScrollLinkedVideoScrubberProps {
src: string;
className?: string;
videoClassName?: string;
scroller?: React.RefObject<HTMLElement | null>;
poster?: string;
muted?: boolean;
playsInline?: boolean;
scrollHeight?: string;
}
export function ScrollLinkedVideoScrubber({
src,
className,
videoClassName,
scroller,
poster,
muted = true,
playsInline = true,
scrollHeight = "300%",
}: ScrollLinkedVideoScrubberProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [stickyHeight, setStickyHeight] = useState("100vh");
useEffect(() => {
const container = scroller?.current;
if (!container) {
setStickyHeight("100vh");
return;
}
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setStickyHeight(`${entry.contentRect.height}px`);
}
});
ro.observe(container);
setStickyHeight(`${container.clientHeight}px`);
return () => ro.disconnect();
}, [scroller]);
useEffect(() => {
const wrapper = wrapperRef.current;
const video = videoRef.current;
if (!(wrapper && video)) {
return;
}
const getContainer = () => scroller?.current ?? null;
const update = () => {
if (
!Number.isFinite(video.duration) ||
video.duration === 0
) {
return;
}
const container = getContainer();
const viewportTop = container
? container.getBoundingClientRect().top
: 0;
const viewportHeight = container
? container.clientHeight
: window.innerHeight;
const wrapperRect = wrapper.getBoundingClientRect();
const scrollableRange =
wrapperRect.height - viewportHeight;
if (scrollableRange <= 0) {
return;
}
const scrolled = viewportTop - wrapperRect.top;
const progress = Math.max(
0,
Math.min(1, scrolled / scrollableRange),
);
video.currentTime = progress * video.duration;
};
const scrollTarget = getContainer() ?? window;
scrollTarget.addEventListener("scroll", update, {
passive: true,
});
video.addEventListener("loadedmetadata", update);
update();
return () => {
scrollTarget.removeEventListener("scroll", update);
video.removeEventListener("loadedmetadata", update);
};
}, [scroller]);
return (
<div
ref={wrapperRef}
className={cn("relative w-full", className)}
style={{ height: scrollHeight }}
>
<div
className="sticky top-0 w-full overflow-hidden"
style={{ height: stickyHeight }}
>
<video
ref={videoRef}
src={src}
poster={poster}
muted={muted}
playsInline={playsInline}
preload="auto"
className={cn(
"block h-full w-full object-cover",
videoClassName,
)}
/>
</div>
</div>
);
}Installation
1. Copy the component file
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface ScrollLinkedVideoScrubberProps {
src: string;
className?: string;
videoClassName?: string;
scroller?: React.RefObject<HTMLElement | null>;
poster?: string;
muted?: boolean;
playsInline?: boolean;
scrollHeight?: string;
}
export function ScrollLinkedVideoScrubber({
src,
className,
videoClassName,
scroller,
poster,
muted = true,
playsInline = true,
scrollHeight = "300%",
}: ScrollLinkedVideoScrubberProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [stickyHeight, setStickyHeight] = useState("100vh");
useEffect(() => {
const container = scroller?.current;
if (!container) {
setStickyHeight("100vh");
return;
}
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setStickyHeight(`${entry.contentRect.height}px`);
}
});
ro.observe(container);
setStickyHeight(`${container.clientHeight}px`);
return () => ro.disconnect();
}, [scroller]);
useEffect(() => {
const wrapper = wrapperRef.current;
const video = videoRef.current;
if (!(wrapper && video)) {
return;
}
const getContainer = () => scroller?.current ?? null;
const update = () => {
if (
!Number.isFinite(video.duration) ||
video.duration === 0
) {
return;
}
const container = getContainer();
const viewportTop = container
? container.getBoundingClientRect().top
: 0;
const viewportHeight = container
? container.clientHeight
: window.innerHeight;
const wrapperRect = wrapper.getBoundingClientRect();
const scrollableRange =
wrapperRect.height - viewportHeight;
if (scrollableRange <= 0) {
return;
}
const scrolled = viewportTop - wrapperRect.top;
const progress = Math.max(
0,
Math.min(1, scrolled / scrollableRange),
);
video.currentTime = progress * video.duration;
};
const scrollTarget = getContainer() ?? window;
scrollTarget.addEventListener("scroll", update, {
passive: true,
});
video.addEventListener("loadedmetadata", update);
update();
return () => {
scrollTarget.removeEventListener("scroll", update);
video.removeEventListener("loadedmetadata", update);
};
}, [scroller]);
return (
<div
ref={wrapperRef}
className={cn("relative w-full", className)}
style={{ height: scrollHeight }}
>
<div
className="sticky top-0 w-full overflow-hidden"
style={{ height: stickyHeight }}
>
<video
ref={videoRef}
src={src}
poster={poster}
muted={muted}
playsInline={playsInline}
preload="auto"
className={cn(
"block h-full w-full object-cover",
videoClassName,
)}
/>
</div>
</div>
);
}2. Import and use
import { useRef } from "react";
import { ScrollLinkedVideoScrubber } from "@/components/scroll-linked-video-scrubber";
export function VideoDemo() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="h-screen overflow-y-auto"
>
<ScrollLinkedVideoScrubber
src="/video.mp4"
scroller={containerRef}
/>
</div>
);
}Usage
Import
Add the imports.
import { useRef } from "react";
import { ScrollLinkedVideoScrubber } from "@/components/scroll-linked-video-scrubber";Use
Wrap in a scrollable container. Pass scroller ref. scrollHeight controls scrub speed.
const containerRef = useRef<HTMLDivElement>(null);
<div
ref={containerRef}
className="h-screen overflow-y-auto"
>
<ScrollLinkedVideoScrubber
src="/components/scroll-linked-video-scrubber/demo.mp4"
scroller={containerRef}
scrollHeight="500%"
/>
</div>;Guidelines
- Pass src for the video URL. Video must be same-origin or CORS-enabled for duration access.
- When inside a scrollable container (demo, modal), pass scroller={containerRef}.
- scrollHeight controls how much scroll is needed to play the full video (default '300%').
- No extra spacer divs needed - the component creates its own scroll runway.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| srcrequired | string | - | Video source URL. |
| className | string | - | Additional CSS classes for the outer wrapper. |
| videoClassName | string | - | Classes for the video element. |
| scroller | React.RefObject<HTMLElement | null> | - | Ref to the scroll container. When inside overflow-y-auto, pass its ref so scroll events track that element. |
| poster | string | - | Poster image URL. |
| muted | boolean | true | Mute the video (recommended for autoplay policies). |
| playsInline | boolean | true | playsInline for mobile. |
| scrollHeight | string | "300%" | Height of the scroll runway. Larger values = slower scrub. Accepts any CSS height value. |
Accessibility
- Decorative scroll-driven media effect with no interactive controls; the video element has no controls attribute, so there are no focusable play, pause, or seek buttons to operate by keyboard.
- The video exposes no ARIA role, label, or text alternative and ships no captions or track element, so non-visual users get no equivalent for the scrubbed footage; add an accessible name and captions if the video conveys meaningful information.
- Playback is driven entirely by scroll position, which is keyboard reachable via normal scrolling, but there is no independent control to pause or scrub the media on its own.
- It does not check prefers-reduced-motion, so the scroll-linked motion plays for everyone; it should be guarded with a matchMedia('(prefers-reduced-motion: reduce)') query to hold a static frame or poster for users who prefer reduced motion.
Performance
- Each scroll tick sets video.currentTime to map progress onto the timeline, which triggers per-frame video decode and seeking; this is heavier than animating transform or opacity and can stutter on long or high-resolution clips.
- The scroll listener is registered with passive true so it never blocks the scroll thread, and pinning is done with CSS position sticky rather than JS-driven top offsets, keeping layout cheap.
- It reads getBoundingClientRect inside the scroll handler on every event without requestAnimationFrame throttling or IntersectionObserver gating, so updates fire even when the wrapper is offscreen and can cause layout reads on every scroll frame.
- A ResizeObserver tracks the scroll container height to size the sticky region, and both the scroll and loadedmetadata listeners plus the observer are disconnected in their effect cleanups, so no listeners or observers leak.
- There is no visibility or reduced-motion gating, so decoding and seeking continue regardless of whether the video is in view or the user prefers reduced motion.
Examples
Basic
Basic usage. Pass scroller ref when inside a scrollable container.
import { useRef } from "react";
import { ScrollLinkedVideoScrubber } from "@/components/scroll-linked-video-scrubber";
export function VideoScrubberDemo() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="h-96 overflow-y-auto"
>
<ScrollLinkedVideoScrubber
src="/components/scroll-linked-video-scrubber/demo.mp4"
scroller={containerRef}
scrollHeight="500%"
/>
</div>
);
}Related reading
- Scroll Indicator with Framer Motion — Mapping scroll progress to media, like a scrubber
- Smooth Scroll in React — Smoothing scroll-linked playback
Last updated on Jun 19