Scroll-Linked Video Scrubber
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
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/scroll-linked-video-scrubber.json"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. |
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>
);
}