Build scroll-driven narrative experiences that reveal content, trigger animations, and create immersive storytelling as users scroll.
What is Scrollytelling?
Definition
"A storytelling format in which visual and textual elements appear or change as the reader scrolls through an online article." When readers scroll, something other than conventional document movement happens.
Origin
The New York Times' "Snow Fall: The Avalanche at Tunnel Creek" (2012), which won the 2013 Pulitzer Prize for Feature Writing.
Why it works
Scrollytelling exploits a fundamental psychological principle—humans crave control. Every scroll is a micro-commitment that increases engagement. Users control the pace, creating deeper connection than passive consumption.
Measured impact
:
400% longer time-on-page vs static content
67% improvement in information recall
5x higher social sharing rates
25-40% improved conversion completion
Core Principles
1. Story First, Technology Second
The biggest mistake is leading with technology instead of narrative. Scrollytelling should enhance the story, not showcase effects.
2. User Agency & Progressive Disclosure
Users control the pace. Information reveals gradually to maintain curiosity. This shifts from predetermined pacing to user-controlled narrative flow.
3. Sequential Structure
Unlike hierarchical web content, scrollytelling demands linear progression with clear narrative beats. Each section builds on the previous.
4. Meaningful Change
Every scroll-triggered effect must serve the narrative. Gratuitous animation distracts rather than enhances.
5. Restraint Over Spectacle
Not every section needs animation. Subtle transitions often work better than constant effects. The format should amplify the content's message, not fight it.
The 5 Standard Techniques
Research analyzing 50 scrollytelling articles identified these core patterns:
Technique
Description
Best For
Graphic Sequence
Discrete visuals that change completely at scroll thresholds
Data visualizations, step-by-step explanations
Animated Transition
Smooth morphing between states
State changes, evolution over time
Pan and Zoom
Scroll controls which portion of a visual is visible
The classic scrollytelling pattern: a graphic becomes "stuck" while narrative text scrolls alongside. When the narrative concludes, the graphic "unsticks."
Parallax triggers vestibular disorders (dizziness, nausea, migraines). Always provide reduced-motion fallback; limit to one subtle parallax effect per page maximum.
Pattern 4: Multi-Directional
Combines vertical scrolling with horizontal sections or sideways timelines.
When to use
Timeline-based content, visually-driven showcases, unconventional layouts where surprise enhances the message.
When to Use Scrollytelling
Good candidates
:
Long-form journalism with multimedia
Brand storytelling celebrating achievements
Product pages showcasing features
Chronological/historical content
Complex narratives broken into digestible chunks
High-consideration products needing depth
Avoid when
:
You lack strong visual assets
You're tight on time/budget (good scrollytelling requires more investment)
The story lacks distinct chronology
Content is brief
Performance is critical on low-end devices
Discovery Questions
Before implementing, clarify with the user:
header: "Scrollytelling Pattern"
question: "What scrollytelling pattern fits your narrative?"
options:
- "Pinned narrative - text changes while visual stays fixed (NYT, Pudding.cool style)"
- "Progressive reveal - content fades in as you scroll down"
- "Parallax depth - layers move at different speeds (requires reduced-motion fallback)"
- "Step sequence - discrete sections with transitions between"
- "Hybrid - multiple patterns combined"
header: "Tech Stack"
question: "What's your frontend setup?"
options:
- "React + Tailwind"
- "React + CSS-in-JS"
- "Next.js"
- "Vue"
- "Vanilla JS"
- "Other"
header: "Animation Approach"
question: "Animation library preference?"
options:
- "CSS-only (scroll-timeline API, IntersectionObserver) - best performance"
- "GSAP ScrollTrigger - most powerful, cross-browser"
- "Framer Motion / Motion - React ecosystem"
- "Lenis + custom - smooth scroll"
- "No preference - recommend based on complexity"
Technical Implementation (2025-2026)
Technology Selection Guide
Complexity
Recommendation
Bundle Size
Simple reveals, progress bars
Native CSS scroll-timeline
0 KB
Viewport-triggered effects
IntersectionObserver
0 KB
Complex timelines, pinning
GSAP ScrollTrigger
~23 KB
React projects
Motion (Framer Motion)
~32 KB
Smooth scroll + effects
Lenis + GSAP
~25 KB
CSS Scroll-Driven Animations (Native - 2025+)
Browser support
:
Chrome 115+: Full support (since July 2025)
Safari 26+: Full support (since September 2025)
Firefox: Requires flag (
layout.css.scroll-driven-animations.enabled
)
Key properties
:
animation-timeline: scroll()
- links animation to scroll position
animation-timeline: view()
- links animation to element visibility
animation-range
- controls when animation starts/stops
Example - View-triggered fade in
:
@supports
(
animation-timeline
:
scroll
(
)
)
{
.reveal-on-scroll
{
animation
:
reveal linear both
;
animation-timeline
:
view
(
)
;
animation-range
:
entry
0
%
entry
100
%
;
}
@keyframes
reveal
{
from
{
opacity
:
0
;
transform
:
translateY
(
30
px
)
;
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
)
;
}
}
}
Example - Scroll-linked progress bar
:
.progress-bar
{
animation
:
grow linear
;
animation-timeline
:
scroll
(
)
;
}
@keyframes
grow
{
from
{
transform
:
scaleX
(
0
)
;
}
to
{
transform
:
scaleX
(
1
)
;
}
}
Performance benefit
Tokopedia achieved 80% code reduction and CPU usage dropped from 50% to 2% by switching to native CSS scroll-driven animations.
IntersectionObserver Pattern
For scroll-triggered effects without continuous scroll tracking:
const
RevealOnScroll
=
(
{
children
,
delay
=
0
}
)
=>
{
const
ref
=
useRef
(
null
)
;
const
[
isVisible
,
setIsVisible
]
=
useState
(
false
)
;
useEffect
(
(
)
=>
{
// Check reduced motion preference
if
(
window
.
matchMedia
(
'(prefers-reduced-motion: reduce)'
)
.
matches
)
{
setIsVisible
(
true
)
;
return
;
}
const
observer
=
new
IntersectionObserver
(
(
[
entry
]
)
=>
{
if
(
entry
.
isIntersecting
)
{
setIsVisible
(
true
)
;
observer
.
disconnect
(
)
;
}
}
,
{
threshold
:
0.1
,
rootMargin
:
'-50px'
}
)
;
if
(
ref
.
current
)
observer
.
observe
(
ref
.
current
)
;
return
(
)
=>
observer
.
disconnect
(
)
;
}
,
[
]
)
;
return
(
<
div
ref
=
{
ref
}
style
=
{
{
opacity
:
isVisible
?
1
:
0
,
transform
:
isVisible
?
'translateY(0)'
:
'translateY(30px)'
,
transition
:
`
all 0.6s ease
${
delay
}
ms
`
,
}
}
>
{
children
}
</
div
>
)
;
}
;
GSAP ScrollTrigger (Complex Animations)
For pinned sections, timeline orchestration, and cross-browser reliability:
// 0 when element top enters viewport, 1 when bottom exits
const
start
=
rect
.
top
-
windowHeight
;
const
end
=
rect
.
bottom
;
const
current
=
-
start
;
const
total
=
end
-
start
;
setProgress
(
Math
.
max
(
0
,
Math
.
min
(
1
,
current
/
total
)
)
)
;
}
;
window
.
addEventListener
(
'scroll'
,
updateProgress
,
{
passive
:
true
}
)
;
updateProgress
(
)
;
return
(
)
=>
window
.
removeEventListener
(
'scroll'
,
updateProgress
)
;
}
,
[
ref
]
)
;
return
progress
;
}
;
Accessibility Requirements
Accessibility is non-negotiable. Scrollytelling can trigger vestibular disorders and exclude keyboard/screen reader users if not implemented correctly.
Triggers vestibular issues - always provide fallback
Swooping, zooming
Problematic - avoid or provide fallback
Looping animations
Cognitive overload - limit iterations
Keep effects small
Animations affecting more than 1/3 of the viewport can overwhelm users.
Keyboard Navigation
Add
tabindex="0"
to scrollable areas
Ensure focus follows scroll targets when using smooth scrolling
Provide skip links to major sections
No keyboard traps in scroll regions
Screen Reader Considerations
Use proper heading hierarchy (
through
)
DOM order must match logical reading order
Use ARIA live regions for dynamic content updates
Ensure all content exists in DOM (even if visually hidden initially)
Performance Best Practices
Do
Use
transform
and
opacity
for animations (GPU-accelerated)
Add
will-change: transform
sparingly on animated elements
Use
passive: true
on scroll listeners
Use
position: sticky
over JS-based pinning
Lazy load images/videos until needed
Use single IntersectionObserver for multiple elements
Test on real devices, not just desktop
Don't
Animate
width
,
height
,
top
,
left
(triggers layout recalculation)
Use
will-change
excessively (increases memory usage)
Create scroll listeners without cleanup
Forget to handle reduced-motion preferences
Use
overflow: hidden
on ancestors of sticky elements
Performance Targets
First Contentful Paint: under 2.5 seconds
Maintain 60fps during scrolling
Meet Core Web Vitals thresholds
Mobile Considerations
Mobile users represent 60%+ of web traffic. Scrollytelling must work excellently on mobile or risk excluding the majority of users.
Mobile-First Design Philosophy
Start with mobile
: "Starting with mobile first forces you to pare down your experience to the nuts and bolts, leaving only the necessities. This refines and focuses the content." (The Pudding)
Design the core experience for mobile, then enhance for desktop—not the reverse. This approach:
Forces essential-only content decisions
Improves development efficiency
Results in less code if desktop is functionally similar
Viewport Units: vh vs svh vs dvh vs lvh
Mobile browsers toggle navigation bars during scrolling, breaking traditional
100vh
layouts.
Unit
Definition
When To Use
vh
Large viewport (browser UI hidden)
Legacy fallback only
svh
Small viewport (browser UI visible)
Use for ~90% of layouts
(recommended)
lvh
Large viewport (browser UI hidden)
Modals/overlays maximizing space
dvh
Dynamic viewport (changes constantly)
Use sparingly
- causes layout thrashing
Critical warning
: "I initially thought 'dynamic viewport units are the future' and used dvh for every element. This was a mistake. The constant layout shifts felt broken."
Implementation pattern
:
.full-height-section
{
height
:
100
vh
;
/* Fallback for older browsers */
height
:
100
svh
;
/* Modern solution - small viewport */
}
/* Progressive enhancement */
@supports
(
height
:
100
svh
)
{
:root
{
--viewport-height
:
100
svh
;
}
}
JavaScript alternative
(The Pudding's recommendation):
function
setViewportHeight
(
)
{
const
vh
=
window
.
innerHeight
;
document
.
documentElement
.
style
.
setProperty
(
'--vh'
,
`
${
vh
}
px
`
)
;
}
window
.
addEventListener
(
'resize'
,
setViewportHeight
)
;
setViewportHeight
(
)
;
.section
{
height
:
calc
(
var
(
--vh
)
*
100
)
;
}
Touch Scroll Physics
Momentum scrolling
: Content continues scrolling after touch release, decelerating naturally. iOS and Android have different friction curves—iOS feels more "flicky."
Critical for iOS
:
.scroll-container
{
overflow-y
:
auto
;
-webkit-overflow-scrolling
:
touch
;
/* iOS momentum - still needed for pre-iOS 13 */
}
Performance note
: Scroll events fire at END of momentum on iOS, not during. Use IntersectionObserver instead of scroll listeners for step detection.
Preventing Gesture Conflicts
Pull-to-Refresh conflicts
:
/* Disable PTR but keep bounce effects */
html
{
overscroll-behavior-y
:
contain
;
}
/* Or disable completely */
html
{
overscroll-behavior-y
:
none
;
}
Scroll chaining in modals
:
.modal-content
{
overflow-y
:
auto
;
overscroll-behavior
:
contain
;
/* Prevents scrolling parent when modal hits boundary */
}
Horizontal swipe conflicts
(browser back/forward):
.horizontal-carousel
{
touch-action
:
pan-y pinch-zoom
;
/* Allow vertical scroll & zoom, block horizontal */
}
Passive Event Listeners
Chrome 56+ defaults touch listeners to passive for 60fps scrolling. Use passive listeners for monitoring, non-passive only when you must
preventDefault()
:
// ✅ Monitoring scroll (passive - default, best performance)
document
.
addEventListener
(
'touchstart'
,
trackTouch
,
{
passive
:
true
}
)
;
// ⚠️ Only when you MUST prevent default (e.g., custom swipe)
carousel
.
addEventListener
(
'touchmove'
,
handleSwipe
,
{
passive
:
false
}
)
;
Prefer CSS over JavaScript
:
/* Better than JavaScript preventDefault */
.element
{
touch-action
:
pan-y pinch-zoom
;
}
Scroll Snap on Mobile
Scroll snap works well on mobile with
mandatory
(avoid
proximity
on touch devices):
.scroll-container
{
scroll-snap-type
:
y mandatory
;
overflow-y
:
auto
;
-webkit-overflow-scrolling
:
touch
;
}
.section
{
scroll-snap-align
:
start
;
min-height
:
100
svh
;
}
/* Accessibility */
@media
(
prefers-reduced-motion
:
reduce
)
{
.scroll-container
{
scroll-snap-type
:
none
;
scroll-behavior
:
auto
;
}
}
Warning
: Never use
mandatory
if content can overflow the viewport—users won't be able to scroll to see it.
Touch Accessibility
Minimum touch target sizes
:
Standard
Size
When
WCAG 2.5.8 (AA)
24×24px
Minimum compliance
WCAG 2.5.5 (AAA)
44×44px
Best practice
Apple iOS
44×44pt
Recommended
Android
48×48dp
Recommended
Expand touch area without changing visual size
:
.small-button
{
width
:
24
px
;
height
:
24
px
;
padding
:
10
px
;
/* Creates 44×44px touch target */
}
Always provide button alternatives for gesture-only actions
:
<
div
class
=
"
item
"
>
<
span
>
Content
span
>
<
button
aria-label
=
"
Delete
"
>
Delete
button
>
div
>
Browser-Specific Quirks
iOS Safari
:
/* Position sticky requires no overflow on ancestors */
.parent
{
/* overflow: hidden; ❌ Breaks sticky on iOS */
}
.sticky
{
position
:
-webkit-sticky
;
/* Prefix still needed */
position
:
sticky
;
top
:
0
;
}
/* Preventing body scroll in modals requires JavaScript on iOS */
/* CSS overflow: hidden doesn't work on body */
// iOS modal scroll lock
function
lockScroll
(
)
{
document
.
body
.
style
.
position
=
'fixed'
;
document
.
body
.
style
.
top
=
`
-
${
window
.
scrollY
}
px
`
;
}
Chrome Android
:
/* Disable pull-to-refresh */
body
{
overscroll-behavior-y
:
none
;
}
Responsive Layout Strategy
Side-by-Side → Stacked pattern
:
<
div
className
=
"
grid grid-cols-1 md:grid-cols-2 gap-8
"
>
{
/* On mobile: stacked, full-width */
}
{
/* On desktop: side-by-side sticky */
}
<
div
className
=
"
space-y-[50vh] md:space-y-[100vh]
"
>
{
/* Text steps - shorter spacing on mobile */
}
div
>
<
div
className
=
"
hidden md:block
"
>
{
/* Sticky visual - hidden on mobile, shown on desktop */
}
div
>
div
>
Synchronize CSS and JS breakpoints
:
const
breakpoint
=
'(min-width: 800px)'
;
const
isDesktop
=
window
.
matchMedia
(
breakpoint
)
.
matches
;
if
(
isDesktop
)
{
initScrollama
(
)
;
// Complex scrollytelling
}
else
{
initStackedView
(
)
;
// Simple stacked layout
}
// Listen for breakpoint changes
window
.
matchMedia
(
breakpoint
)
.
addEventListener
(
'change'
,
(
e
)
=>
{
if
(
e
.
matches
)
{
initScrollama
(
)
;
}
else
{
initStackedView
(
)
;
}
}
)
;
Mobile alternative patterns
:
Replace sticky graphics with inline graphics between text sections
Use simpler reveal animations instead of complex parallax
Stack static images with scroll-triggered captions
Consider whether scrollytelling is even appropriate
Mobile Performance Strategies
Target: 60fps (16.7ms per frame)
Use hardware-accelerated properties only
:
.animate
{
/* ✅ Good - GPU accelerated */
transform
:
translateY
(
100
px
)
;
opacity
:
0.5
;
/* ❌ Bad - triggers layout recalculation */
/* top: 100px; width: 200px; margin: 20px; */
}
Use
will-change
sparingly
:
/* Only on elements about to animate */
.about-to-animate
{
will-change
:
transform
;
}
/* Remove when animation completes */
.animation-complete
{
will-change
:
auto
;
}
Warning
: Too many composited layers hurt mobile performance. Don't apply
will-change
to everything.
Throttle scroll handlers with requestAnimationFrame
:
let
ticking
=
false
;
window
.
addEventListener
(
'scroll'
,
(
)
=>
{
if
(
!
ticking
)
{
requestAnimationFrame
(
(
)
=>
{
updateAnimation
(
)
;
ticking
=
false
;
}
)
;
ticking
=
true
;
}
}
,
{
passive
:
true
}
)
;
Better: Use IntersectionObserver
(no scroll events):
const
observer
=
new
IntersectionObserver
(
entries
=>
{
entries
.
forEach
(
entry
=>
{
if
(
entry
.
isIntersecting
)
{
animateElement
(
entry
.
target
)
;
}
}
)
;
}
)
;
When to Simplify or Abandon Scrollytelling on Mobile
Keep scrollytelling when
:
Transitions are truly meaningful to the narrative
Spatial movement or temporal change is core to understanding
Performance targets can be met (60fps, <3s load)
Testing shows mobile users successfully comprehend content
Simplify scrollytelling when
:
Performance is acceptable but animations aren't essential
Some complexity is nice-to-have but not required
Desktop experience is richer but mobile should be functional
Abandon scrollytelling when
:
Performance issues can't be resolved on mid-tier devices
Mobile users are confused or frustrated in testing
Development timeline doesn't allow proper optimization
Content works just as well in simpler stacked format
Animations are decorative, not meaningful
"The most important reason to preserve scroll animations is if the transitions are truly meaningful, not just something to make it pop."
(The Pudding)
Mobile Testing Checklist
Device Coverage
:
iPhone (latest 2 models) - Safari
iPhone - Chrome
iPad - Safari (portrait & landscape)
Android flagship - Chrome
Android mid-tier - Chrome (critical for performance)
Tablet Android - Chrome
Viewport Testing
:
Address bar hide/show transitions smooth
No layout jumping during scroll
Fixed elements stay positioned correctly
svh/dvh units behaving as expected
Performance Testing
:
60fps maintained during scroll (use Chrome DevTools FPS meter)
No janky animations
Images lazy-load properly
Memory doesn't leak on long sessions
Test with CPU throttling (4x slowdown in DevTools)
Interaction Testing
:
Touch scrolling feels natural (momentum)
No accidental interactions
Pull-to-refresh disabled if needed
Scroll chaining behaves correctly
Accessibility Testing
:
Works with reduced motion enabled
Touch targets minimum 44×44px
Button alternatives for all gestures
Screen reader (VoiceOver/TalkBack) can navigate
Critical
: Chrome DevTools mobile emulator does NOT accurately simulate browser UI behavior. Test on real devices or use BrowserStack/Sauce Labs
Anti-Patterns to Avoid
Anti-Pattern
Problem
Solution
Scroll-jacking
Overrides natural scroll, breaks accessibility
Preserve native scroll behavior
Text-graphics conflict
User can't read text while watching animation
Separate text and animated areas
Animation overload
Distracts from content, causes fatigue
Restraint—not every section needs effects
No length indicator
Users don't know commitment level
Add progress indicators
Missing fallbacks
Breaks for no-JS or reduced-motion users
Progressive enhancement
Mobile neglect
Excludes 60% of users
Mobile-first design
Poor pacing
Too fast or too slow content reveals
Test with real users
Implementation Workflow
Understand the narrative
- What story are you telling? What's the sequence?
Choose pattern
- Pinned, progressive, parallax, or hybrid?
Plan accessibility
- How will reduced-motion users experience this?
Select technology
- Native CSS, GSAP, Motion based on complexity
Scaffold structure
- Build HTML/component structure first
Add scroll mechanics
- Implement tracking (IntersectionObserver, ScrollTrigger, etc.)
Wire animations
- Connect scroll state to visual changes
Add reduced-motion fallbacks
- Content should work without animation
Performance audit
- Check for jank, optimize
Cross-device testing
- Mobile, tablet, desktop, different browsers
Accessibility testing
- Keyboard nav, screen readers, reduced motion
Output
After gathering requirements, implement the scrollytelling experience directly in the codebase. Provide:
Component structure with scroll tracking
Animation/transition logic with reduced-motion handling
Responsive adjustments (mobile-first)
Accessible fallbacks
Performance optimizations
Testing recommendations
Notable Examples for Reference
NYT "Snow Fall"
- Origin story, multimedia integration
Pudding.cool
- Data journalism with audio-visual sync
National Geographic "Atlas of Moons"
- Educational, responsive design
BBC "Partition of India"
- Historical narrative with multimedia
Firewatch website
- Multi-layered parallax for atmosphere
Sources
Research based on 100+ sources including EU Data Visualization Guide, MDN, GSAP documentation, W3C WCAG, A List Apart, Smashing Magazine, The Pudding, CSS-Tricks, and academic research on scrollytelling effectiveness.