React Scroll Media 🎬
Production-ready, cinematic scroll sequences for React.
Zero scroll-jacking. Pure sticky positioning. 60fps performance.
react-scroll-media is a lightweight library for creating Apple-style "scrollytelling" image sequences. It maps scroll progress to image frames deterministically, using standard CSS sticky positioning for a native, jank-free feel.
✨ Features
- 🚀 Native Performance:
- Uses
requestAnimationFrame for buttery smooth 60fps rendering.
- No Scroll Jacking: We never hijack the scrollbar. It works with native scrolling.
- CSS Sticky: Uses relatively positioned containers with sticky inner content.
- 🖼️ Flexible Loading:
- Manual: Pass an array of image URLs.
- Pattern: Generate sequences like
/img_{index}.jpg.
- Manifest: Load sequences from a JSON manifest.
- 🧠 Smart Memory Management:
- Lazy Mode: Keeps only ±3 frames in memory for huge sequences (800+ frames).
- Eager Mode: Preloads everything for maximum smoothness on smaller sequences.
- Decoding: Uses
img.decode() to prevent main-thread jank during painting.
- 🛠️ Developer Experience:
- Debug Overlay: Visualize progress and frame index in real-time.
- Hooks: Exported
useScrollSequence for custom UI implementations.
- TypeScript: First-class type definitions.
- SSR Safe: Works perfectly with Next.js / Remix / Gatsby.
- A11y: Built-in support for
prefers-reduced-motion and ARIA attributes.
- Robust: Error boundaries and callbacks for image load failures.
🤔 When to use this vs Video?
Feature
Video (<video>)
Scroll Sequence (react-scroll-media)
Quality
Compressed (artifacts)
Lossless / Exact Frames (CRISP)
Transparency
Difficult (needs webm/hevc)
Native PNG/WebP Transparency (Easy)
Scrubbing
Janky (keyframe dependency)
1:1 Instant Scrubbing
Mobile
Auto-play often blocked
Works everywhere
File Size
Small
Large (requires optimization/lazy loading)
Use Scroll Sequence when you need perfect interaction, transparency, or crystal-clear product visuals (like Apple). Use Video for long, non-interactive backgrounds.
📦 Installation
npm install react-scroll-media
🚀 Usage
Basic Example
The simplest way to use it is with the ScrollSequence component.
import { ScrollSequence } from 'react-scroll-media';const frames = [ '/images/frame_01.jpg', '/images/frame_02.jpg',
✨ Scrollytelling & Composition
You can nest components inside ScrollSequence. They will be placed in the sticky container and can react to the timeline.
Animated Text (ScrollText)
Animate opacity and position based on scroll progress (0 to 1). Supports enter and exit phases.
import { ScrollSequence, ScrollText } from 'react-scroll-media';<ScrollSequence source={...} scrollLength="400vh"> {/* Fade In (0.1-0.2) -> Hold -> Fade Out (0.8-0.9) */} <ScrollText start={0.1} end={0.2} exitStart={0.8} exitEnd={0.9} translateY={50} className="my-text-overlay" > Cinematic Experience </ScrollText></ScrollSequence>
Word Reveal (ScrollWordReveal)
Reveals text word-by-word as you scroll.
import { ScrollWordReveal } from 'react-scroll-media';<ScrollWordReveal text="Experience the smooth cinematic scroll." start={0.4} end={0.6} style={{ fontSize: '2rem', color: 'white' }}/>
Advanced: Custom Hooks
For full control over the specialized UI, use the headless hooks.
useScrollSequence
Manages the canvas image controller.
import { useScrollSequence } from 'react-scroll-media';const CustomScroller = () => {
useScrollTimeline
Subscribe to the scroll timeline in any component.
import { useScrollTimeline } from 'react-scroll-media';const MyComponent = () => { const { subscribe } = useScrollTimeline();
⚙️ Configuration
ScrollSequence Props
Prop
Type
Default
Description
source
SequenceSource
Required
Defines where images come from.
scrollLength
string
"300vh"
Height of the container (animation duration).
memoryStrategy
"eager" | "lazy"
"eager"
Optimization strategy.
lazyBuffer
number
10
Number of frames to keep loaded in lazy mode.
fallback
ReactNode
null
Loading state component.
accessibilityLabel
string
"Scroll sequence"
ARIA label for the canvas. Example: "360 degree view of the product".
debug
boolean
false
Shows debug overlay.
onError
(error: Error) => void
undefined
Callback fired when an image fails to load or initialization errors occur.
📊 Performance & compatibility
Bundle Size
- Minified: ~22.0 kB
- Gzipped: ~6.08 kB
- Zero dependencies (uses native Canvas API, no heavyweight libraries).
Browser Support
Browser
Status
Note
Chrome
✅
Full support (OffscreenCanvas enabled)
Firefox
✅
Full support
Safari
✅
Full support (Desktop & Mobile)
Edge
✅
Full support
IE11
❌
Not supported (Missing ES6/Canvas features)
Accessibility (A11y)
- Keyboard Navigation: Users can scrub through the sequence using standard keyboard controls (Arrow Keys, Spacebar, Page Up/Down) because it relies on native scrolling.
- Screen Readers: Add
accessibilityLabel to ScrollSequence to provide a description for the canvas. Canvas has role="img".
- Reduced Motion: Automatically detects
prefers-reduced-motion: reduce. If enabled, ScrollSequence will disable the scroll animation and display the fallback content (if provided) or simply freeze the first frame to prevent motion sickness.
Memory Usage (Benchmarks)
Tested on 1080p frames.
Frames
Strategy
Memory
Recommendation
100
eager
~50MB
Instant seeking, smooth.
500
eager
~250MB
High RAM usage.
500+
lazy
~20MB
Recommended. Kept flat constant.
Error Handling & Fallbacks
Network errors are handled gracefully. You can provide a fallback UI that displays while images are loading or if they fail.
<ScrollSequence source={{ type: 'manifest', url: '/bad_url.json' }} fallback={<div className="error">Failed to load sequence</div>} onError={(e) => console.error("Sequence error:", e)}/>
Error Boundaries
For robust production apps, wrap ScrollSequence in an Error Boundary to catch unexpected crashes:
```tsx
class ErrorBoundary extends React.Component<{ fallback: React.ReactNode, children: React.ReactNode }, { hasError: boolean }> {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ScrollSequence source={...} />
</ErrorBoundary>
### Multi-Instance & Nested Scroll
`react-scroll-media` automatically handles multiple instances on the same page. Each instance:
1. Registers with a shared `RAF` loop (singleton) for optimal performance.
2. Calculates its own progress independently.
3. Should have a unique `scrollLength` or container setup.
---
## 🏗️ Architecture
### `SequenceSource` Options
**1. Manual Mode** (Pass array directly)
```ts
{ type: 'manual', frames: ['/img/1.jpg', '/img/2.jpg']}
2. Pattern Mode (Generate URLs)
{ type: 'pattern', url: '/assets/sequence_{index}.jpg',
3. Manifest Mode (Fetch JSON)
{ type: 'manifest', url: '/sequence.json' }
Note: Manifests are cached in memory by URL. To force a refresh, append a query param (e.g. ?v=2).
🏗️ Architecture
How it Works (The "Sticky" Technique)
Unlike libraries that use position: fixed or JS-based scroll locking (which breaks refreshing and feels unnatural), we use CSS Sticky Positioning.
-
Container (relative): This element has the height you specify (e.g., 300vh). It occupies space in the document flow.
-
Sticky Wrapper (sticky): Inside the container, we place a div that is 100vh tall and sticky at top: 0.
-
Canvas: The <canvas> sits inside the sticky wrapper.
-
Math: As you scroll the container, the sticky wrapper stays pinned to the viewport. We calculate:
progress = -containerRect.top / (containerHeight - viewportHeight)
This gives a precise 0.0 to 1.0 value tied to the pixel position of the scrollbar.
Memory Strategy
- "eager" (Default): Best for sequences < 200 frames. Preloads all images into
HTMLImageElement instances. Instant seeking, smooth playback. High memory usage.
- "lazy": Best for long sequences (500+ frames). Only keeps the current frame and its neighbors in memory. Saves RAM, prevents crashes.
- Buffer size defaults to ±10 frames but can be customized via
lazyBuffer.
🐛 Debugging
Enable the debug overlay to inspect your sequence in production:
<ScrollSequence source={...} debug={true} />
Output:
Progress: 0.45Frame: 45 / 100
This overlay is updated directly via DOM manipulation (bypassing React renders) for zero overhead.
MIT © 2026 Thanniru Sai Teja