Production-ready, cinematic scroll sequences for React.
Zero scroll-jacking β’ Pure sticky positioning β’ 60fps performance
π Live Demo β’ Installation β’ Usage β’ API β’ Examples
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.
Above: A 60fps scroll-driven sequence. The animation frame is tied 1:1 to the scroll position, allowing for instant scrubbing and pausing at any angle.
- Uses
requestAnimationFramefor 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
- Manual β Pass an array of image URLs
- Pattern β Generate sequences like
/img_{index}.jpg - Manifest β Load sequences from a JSON manifest
- Lazy Mode β Keeps only Β±10 frames (configurable) 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
- Debug Overlay β Visualize progress and frame index in real-time
- Hooks β Exported
useScrollSequencefor custom UI implementations - TypeScript β First-class type definitions
- SSR Safe β Works perfectly with Next.js / Remix / Gatsby
- A11y β Built-in support for
prefers-reduced-motionand ARIA attributes - Robust β Error boundaries and callbacks for image load failures
| 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.
npm install react-scroll-mediaor
yarn add react-scroll-mediaThe 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',
// ...
];
export default function MyPage() {
return (
<div style={{ height: '200vh' }}>
<h1>Scroll Down</h1>
<ScrollSequence
source={{ type: 'manual', frames }}
scrollLength="300vh" // Determines how long the sequence plays
/>
<h1>Continue Scrolling</h1>
</div>
);
}You can nest components inside ScrollSequence. They will be placed in the sticky container and can react to the timeline.
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>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' }}
/>For full control over the specialized UI, use the headless hooks.
Manages the canvas image controller.
import { useScrollSequence } from 'react-scroll-media';
const CustomScroller = () => {
// ... setup refs
const { containerRef, canvasRef, isLoaded } = useScrollSequence({ ... });
// ... render custom structure
};Subscribe to the scroll timeline in any component.
import { useScrollTimeline } from 'react-scroll-media';
const MyComponent = () => {
const { subscribe } = useScrollTimeline();
// Subscribe to progress (0-1)
useEffect(() => subscribe((progress) => {
console.log('Progress:', progress);
}), [subscribe]);
return <div>...</div>;
};The fit prop controls how images scale within the viewport, using the standard CSS object-fit property.
| Mode | Description | Best Use Case |
|---|---|---|
cover (Default) |
Fills the screen, cropping edges if aspect ratios differ. | Full-screen background sequences. |
contain |
Shows the full image. Letterboxing (bars) may appear. | Product showcases where no part of the image should be cut off. |
fill |
Stretches to fill dimensions. Ignores aspect ratio. | Abstract patterns where distortion is acceptable. |
none |
Original size. No scaling. | Pixel-perfect displays when the wrapper matches image size. |
scale-down |
Smallest of none or contain. |
Responsive layouts where images shouldn't upscale beyond native resolution. |
| 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". |
fit |
"cover" | "contain" | "fill" | "none" | "scale-down" |
"cover" |
Determines how the image is resized to fit its container. |
debug |
boolean |
false |
Shows debug overlay. |
onError |
(error: Error) => void |
undefined |
Callback fired when an image fails to load or initialization errors occur. |
| Metric | Size |
|---|---|
| Minified | ~23.72 kB |
| Gzipped | ~7.11 kB |
β¨ Zero dependencies. Uses native Canvas API, no heavyweight libraries.
| 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) |
-
πΉ 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
accessibilityLabeltoScrollSequenceto provide a description for the canvas. Canvas hasrole="img". -
π Reduced Motion β Automatically detects
prefers-reduced-motion: reduce. If enabled,ScrollSequencewill disable the scroll animation and display thefallbackcontent (if provided) or simply freeze the first frame to prevent motion sickness.
Tested on 1080p frames.
| Frames | Strategy | Memory | Recommendation |
|---|---|---|---|
| 100 | eager |
30MB | Instant seeking, smooth. |
| 500 | eager |
46MB | High RAM usage. |
| 1000 | eager |
57MB | Very high RAM usage. |
| 100 | lazy |
25MB | Low memory usage. |
| 500 | lazy |
30MB | Low memory usage. |
| 1000 | lazy |
45MB | β Recommended. Kept flat constant. |
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)}
/>For robust production apps, wrap ScrollSequence in an Error Boundary to catch unexpected crashes:
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>react-scroll-media automatically handles multiple instances on the same page. Each instance:
- Registers with a shared
RAFloop (singleton) for optimal performance. - Calculates its own progress independently.
- Should have a unique
scrollLengthor container setup.
{
type: 'manual',
frames: ['/img/1.jpg', '/img/2.jpg']
}{
type: 'pattern',
url: '/assets/sequence_{index}.jpg', // {index} is replaced
start: 1, // Start index
end: 100, // End index
pad: 4 // Zero padding (e.g. 1 -> 0001)
}{
type: 'manifest',
url: '/sequence.json'
}
// JSON format: { "frames": ["url1", "url2"] } OR pattern configπ‘ Note: Manifests are cached in memory by URL. To force a refresh, append a query param (e.g.
?v=2).
Unlike libraries that use position: fixed or JS-based scroll locking (which breaks refreshing and feels unnatural), we use CSS Sticky Positioning.
Technical Demo: This visualization shows the direct correlation between the scrollbar position and the rendered frame. The component calculates the exact frame index based on the percentage of the container scrolled, ensuring perfect synchronization without "scroll jacking".
-
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 adivthat is100vhtall andstickyattop: 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. This value is then mapped to the corresponding frame index:
frameIndex = Math.floor(progress * (totalFrames - 1))This approach ensures:
- Zero Jitter: The canvas position is handled by the browser's compositor thread (CSS Sticky).
- Native Feel: Momentum scrolling works perfectly on touchpads and mobile.
- Exact Sync: The frame updates are synchronized with the scroll position in a
requestAnimationFrameloop.
-
"eager" (Default) β Best for sequences < 200 frames. Preloads all images into
HTMLImageElementinstances. 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.
- Buffer size defaults to Β±10 frames but can be customized via
Enable the debug overlay to inspect your sequence in production:
<ScrollSequence
source={...}
debug={true}
/>Output:
Progress: 0.45
Frame: 45 / 100
This overlay is updated directly via DOM manipulation (bypassing React renders) for zero overhead.
react-scroll-media prioritizes security and follows best practices for client-side rendering libraries.
When using Manifest Mode (source.type === 'manifest'), the library makes network requests to fetch your manifest configuration. For security recommendations and best practices, see our SECURITY.md document.
Key Points:
- β Network access is optional (only when using manifest mode)
- β No external dependencies or third-party integrations
- β All processing happens client-side
- β No data collection or telemetry
Built-in Security Hardening (v1.0.5+):
- π HTTPS enforcement β HTTP manifest URLs are rejected
- π Credential isolation β
credentials: 'omit'prevents cookie/auth leakage - π Referrer protection β
referrerPolicy: 'no-referrer'prevents page URL leakage - π Response size limit β 1MB max to prevent memory exhaustion
- π Frame URL whitelist β Only
http:/https:and relative paths allowed;//evil.comrejected - π Frame count cap β Default 2000, ceiling 8000, configurable via
REACT_SCROLL_MEDIA_MAX_FRAMES - π Cache size limit β 50 entry cap with automatic eviction
- π Timeout protection β 10-second abort on slow responses
- π Response validation β Content-type + structure checks
Quick Security Tips:
- Use HTTPS for all manifest URLs
- Only load manifests from trusted sources
- Use Manual or Pattern modes for sensitive environments
- Implement Content Security Policy headers