scrollreel
v0.2.0
Published
Scroll-driven video scrubbing for React. Point it at a video, get a frame-accurate scroll animation.
Downloads
92
Maintainers
Readme
scrollreel
Scroll-driven video scrubbing for React. Point it at a video, get a frame-accurate scroll animation — the effect Apple uses on its product pages.
npm install scrollreelimport { ScrollVideo } from 'scrollreel'
<ScrollVideo src="/hero.mp4" scrollLength="300vh" />Scroll down, the frames advance. Scroll up, they run backwards.
Why this isn't just video.currentTime = progress * duration
That is the obvious implementation, and it looks fine on desktop Chrome with a short test clip. In production it breaks:
- Seeking is keyframe-bound. H.264 with a typical 2-second GOP makes the browser jump to the nearest keyframe and decode forward — up to 60 frames of work per seek at 30fps. You see stutter and frame-snapping.
- Seeking is async and coalesced. Scroll fires far faster than seeks resolve, so intermediate seeks get dropped and motion looks stepped.
- iOS Safari is the hard case. Even with
muted+playsinline+preload="auto",currentTimescrubbing on a normal MP4 often only repaints at keyframes.
So scrollreel keeps that as the API but not the implementation. It ships pluggable engines and a CLI that produces media the engines can actually scrub.
Engines
| Engine | How it works | Smoothness | Payload | Status |
| --- | --- | --- | --- | --- |
| frames | Pre-extracted image sequence painted to canvas | Perfect, every browser | Large, but responsive sets and bisecting load cut what's actually fetched | Recommended |
| video | Real <video>, coalesced currentTime seeks | Good only with a short GOP | Small | Fallback |
| webcodecs | Manual VideoDecoder + frame cache | Perfect, small payload | Small | Not implemented in 0.1 |
engine="auto" (the default) picks frames when you pass a frames prop, otherwise video. It never auto-selects webcodecs.
Prepare your media
The library ships a CLI that wraps ffmpeg. Do not skip this step — feeding <ScrollVideo> a stock MP4 and using the video engine is exactly the janky path described above.
# Recommended: extract an image sequence + manifest.json
npx scrollreel prepare hero.mp4 --out public/hero --frames 180 --width 1920
# Better: responsive sets, so a phone doesn't download desktop-sized frames
npx scrollreel prepare hero.mp4 --widths 768,1280,1920
# Smallest output, if your ffmpeg has libaom
npx scrollreel prepare hero.mp4 --widths 768,1280,1920 --format avif
# Alternative: re-encode with a keyframe every 6 frames so native seeking works
npx scrollreel prepare hero.mp4 --mode keyframes --gop 6Then:
<ScrollVideo frames="/hero/manifest.json" scrollLength="400vh" />Requires ffmpeg and ffprobe on PATH. Run npx scrollreel --help for all options.
Choosing a frame count
--frames 180 over scrollLength="300vh" means roughly one new frame per 13px of scroll on a 1080p screen — smooth without being wasteful. Go higher (240–300) for slow, deliberate hero animations; lower (90–120) for short accent clips. Frame count is the main lever on payload, so tune it before tuning quality.
API
<ScrollVideo>
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| src | string | — | Video URL. Used by video / webcodecs. |
| frames | string \| string[] \| FramesManifest | — | Manifest URL, list of frame URLs, or inline manifest. |
| engine | 'auto' \| 'frames' \| 'video' \| 'webcodecs' | 'auto' | |
| scrollLength | string \| number | '300vh' | How far you scroll to play the whole clip. Bigger = slower. |
| stageHeight | string \| number | '100vh' | Height of the pinned stage. |
| fit | 'cover' \| 'contain' \| 'fill' | 'cover' | |
| axis | 'y' \| 'x' | 'y' | Horizontal needs a horizontally scrolling ancestor. |
| smoothing | number | 0 | Eases progress instead of tracking 1:1. Try 0.85. |
| poster | string | — | Painted until the first frame decodes, instead of black. |
| segments | Segment[] | — | Thresholds firing onEnter / onExit as they're crossed. |
| respectReducedMotion | boolean | true | Holds one frame when the OS asks for reduced motion. |
| reducedMotionFrame | number | 0.5 | Which frame to hold, as 0..1. |
| debug | boolean | false | Overlay: engine, progress, frame index, load %. |
| onProgress | (p: number) => void | — | Called every frame with 0..1. Does not re-render. |
| onReady / onError | callbacks | — | |
| crossOrigin | '' \| 'anonymous' \| 'use-credentials' | — | Needed for cross-origin sources. |
| children | ReactNode | — | Overlaid on the canvas, inside the pinned stage. |
| progressState | boolean | false | Mirror progress into React state. Re-renders every frame. |
useScrollVideo(options)
The headless version — you own the markup.
import { useScrollVideo } from 'scrollreel'
function Hero() {
const { containerRef, canvasRef, progressRef, ready } = useScrollVideo({
frames: '/hero/manifest.json',
})
return (
<div ref={containerRef} style={{ height: '400vh', position: 'relative' }}>
<div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden' }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
</div>
</div>
)
}Returns { containerRef, canvasRef, progressRef, progress, ready, error }.
Read progressRef.current inside your own requestAnimationFrame to drive other animations without re-rendering. progress is only live when progressState: true.
Driving other elements from scroll
const captionRef = useRef<HTMLDivElement>(null)
<ScrollVideo
frames="/hero/manifest.json"
onProgress={(p) => {
if (captionRef.current) captionRef.current.style.opacity = String(p > 0.6 ? 1 : 0)
}}
>
<div ref={captionRef} className="caption">Built for the road</div>
</ScrollVideo>Mutating the DOM from onProgress keeps 60fps. Calling setState there will not.
useScrollProgress(options)
The progress engine on its own, with no video attached — same pinning, smoothing, and segment behaviour. Use it to scrub anything: SVG paths, counters, CSS custom properties.
import { useScrollProgress } from 'scrollreel'
function Bar() {
const barRef = useRef<HTMLDivElement>(null)
const { containerRef } = useScrollProgress({
smoothing: 0.8,
onProgress: (p) => {
if (barRef.current) barRef.current.style.transform = `scaleX(${p})`
},
})
return (
<div ref={containerRef} style={{ height: '200vh' }}>
<div style={{ position: 'sticky', top: 0 }}>
<div ref={barRef} style={{ transformOrigin: 'left' }} />
</div>
</div>
)
}Returns { containerRef, progressRef, progress, reducedMotion }.
Segments
Fire callbacks as progress crosses thresholds — the usual way to sync captions to a scroll animation.
<ScrollVideo
frames="/hero/manifest.json"
segments={[
{ at: 0.33, onEnter: () => setChapter('middle'), onExit: () => setChapter('intro') },
{ at: 0.66, onEnter: () => setChapter('finale'), onExit: () => setChapter('middle') },
]}
/>A fast scroll that jumps past several thresholds at once fires all of them, in order. Dropping the intermediate ones is how scrollytelling captions end up stuck in the wrong state.
Responsive frame sets
--widths emits one directory per width and a manifest with variants:
{
"variants": [
{ "width": 768, "height": 432, "frames": ["768/0001.webp", "..."] },
{ "width": 1920, "height": 1080, "frames": ["1920/0001.webp", "..."] }
]
}The engine measures the canvas, multiplies by DPR (capped at 2), and fetches the smallest variant at least that wide. Flat 0.1-style manifests still work unchanged.
Performance notes
- The render loop is gated by an
IntersectionObserver— an offscreen<ScrollVideo>costs nothing. - The canvas backing store is sized to
devicePixelRatio(capped at 2) so frames aren't resampled twice. - The
framesengine blocks readiness on frame 0 only, then streams the rest in bisecting order (0, n-1, n/2, n/4, 3n/4…) at 6-way concurrency. Because it falls back to the nearest decoded frame, the whole clip is scrubbable almost immediately at coarse temporal resolution and sharpens as frames land — rather than the first 40% working while the rest is missing. - With a responsive manifest, only the variant matching the container's width x DPR (capped at 2x) is fetched.
- Redundant paints are skipped: if the frame index and canvas size are unchanged,
drawImageis not called. - Serve frames with long-lived cache headers; they're immutable.
Accessibility
Pass aria-label to describe the animation — the canvas is exposed as role="img".
prefers-reduced-motion is honoured by default: when the OS asks for reduced motion the component holds a single frame (reducedMotionFrame, default the midpoint) instead of scrubbing, and segment callbacks stop firing. The canvas carries data-scrollreel-reduced-motion so you can style around it. Opt out with respectReducedMotion={false} if you have a good reason.
Browser support
frames works anywhere <canvas> and IntersectionObserver do. video depends on the browser's seek behaviour, so pair it with --mode keyframes. webcodecs needs VideoDecoder (Chrome, Safari 16.4+, Firefox 130+) and is not implemented yet.
The bundles ship a 'use client' banner, so importing ScrollVideo directly from a Next.js App Router server component works without a wrapper.
Roadmap
- [x]
prefers-reduced-motionhandling - [x] Horizontal scroll direction
- [x] AVIF output from the CLI
- [x] Responsive frame sets, bisecting load order, poster, smoothing, segments, debug overlay
- [ ] WebCodecs engine — demux with mp4box.js, bounded
VideoFrameLRU with explicit.close(), directional decode-ahead - [ ] Vite/Next build plugin so frame extraction runs automatically
- [ ] Frame interpolation, so fewer frames still look smooth
License
MIT © Sunny Marthak
