- name
- scrollytelling
- description
- Implements scroll-driven storytelling experiences with pinned sections, progressive reveals, and scroll-linked animations. Use when asked to build scrollytelling, scroll-driven animations, parallax effects, narrative scroll experiences, or story-driven landing pages.
# Scrollytelling Skill
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 | Maps, large images, spatial narratives |
| **Moviescroller** | Frame-by-frame progression creating video-like effects | Product showcases, 3D object reveals |
| **Show-and-Play** | Interactive elements activate at scroll waypoints | Multimedia, audio/video integration |
## Layout Patterns
### Pattern 1: Side-by-Side Sticky (Most Common)
The classic scrollytelling pattern: a graphic becomes "stuck" while narrative text scrolls alongside. When the narrative concludes, the graphic "unsticks."
```
┌─────────────────────────────────────┐
│ ┌──────────┐ ┌─────────────────┐ │
│ │ Text │ │ │ │
│ │ Step 1 │ │ STICKY │ │
│ ├──────────┤ │ GRAPHIC │ │
│ │ Text │ │ │ │
│ │ Step 2 │ │ (updates with │ │
│ ├──────────┤ │ active step) │ │
│ │ Text │ │ │ │
│ │ Step 3 │ │ │ │
│ └──────────┘ └─────────────────┘ │
└─────────────────────────────────────┘
```
**When to use**: Data visualization stories, step-by-step explanations, educational content requiring persistent visual context.
**Implementation**: Use CSS `position: sticky` (not JavaScript scroll listeners) for better performance and graceful degradation.
### Pattern 2: Full-Width Sections
Content spans the entire viewport with section-based transitions.
**When to use**: Highly visual narratives, immersive brand storytelling, portfolio showcases, timeline-based stories.
### Pattern 3: Layered Parallax
Multiple visual layers (background, midground, foreground) move at different speeds to create depth.
**When to use**: Atmospheric storytelling, game/product launches, long-form narratives where depth adds emotional impact.
**Accessibility warning**: 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**:
```css
@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(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
```
**Example - Scroll-linked progress bar**:
```css
.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:
```tsx
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:
```tsx
const ScrollytellingSection = ({ steps }) => {
const containerRef = useRef(null);
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
// Check reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
const ctx = gsap.context(() => {
steps.forEach((_, index) => {
ScrollTrigger.create({
trigger: `.step-${index}`,
start: 'top center',
end: 'bottom center',
onEnter: () => setActiveStep(index),
onEnterBack: () => setActiveStep(index),
});
});
}, containerRef);
return () => ctx.revert();
}, [steps]);
return (
<section ref={containerRef} className="relative">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Text column - scrolls naturally */}
<div className="space-y-[100vh]">
{steps.map((step, i) => (
<div
key={i}
className={`step-${i} min-h-screen flex items-center transition-opacity duration-300 ${
activeStep === i ? 'opacity-100' : 'opacity-30'
}`}
>
<div className="max-w-md">
<h3 className="text-2xl font-bold mb-4">{step.title}</h3>
<p className="text-lg">{step.description}</p>
</div>
</div>
))}
</div>
{/* Visual column - sticky */}
<div className="relative hidden md:block">
<div className="sticky top-0 h-screen flex items-center justify-center">
<StepVisual step={activeStep} data={steps[activeStep]} />
</div>
</div>
</div>
</section>
);
};
```
### Motion (Framer Motion) for React
```tsx
import { motion, useScroll, useTransform } from 'motion/react';
const ScrollLinkedSection = () => {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
});
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);
return (
<section ref={ref} className="h-[200vh] relative">
<motion.div
className="sticky top-0 h-screen flex items-center justify-center"
style={{ opacity, scale }}
>
<h2 className="text-6xl font-bold">Scroll-Linked Content</h2>
</motion.div>
</section>
);
};
```
### Scroll Progress Hook
```tsx
const useScrollProgress = (ref) => {
const [progress, setProgress] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const updateProgress = () => {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight;
// 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.
### Critical WCAG Criteria
| Criterion | Requirement |
|-----------|-------------|
| **SC 2.2.2** | Auto-playing content >5 seconds needs pause/stop/hide controls |
| **SC 2.3.3** | User-triggered animations must be disableable |
| **SC 2.3.1** | No flashing more than 3 times per second |
### Reduced Motion Support (Mandatory)
**Always** respect user preferences:
```css
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
scroll-behavior: auto !important;
}
}
```
```tsx
const prefersReducedMotion = () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// In components - check before animating
useEffect(() => {
GitHub에서 보기