What is scrollytelling?
It's the technique behind those immersive news features and slick product pages: the scroll position drives the
story. Two browser features do most of the heavy lifting — position: sticky (to pin things) and the
IntersectionObserver API (to react when an element enters the screen). Add a dash of CSS transitions and
you've got motion that feels intentional, not gimmicky.
Reveal on scroll
The classic: elements start hidden and fade/rise into place as they scroll into view. IntersectionObserver watches each element and adds a class when it appears. Scroll down — these reveal:
Watch
It observes.
Reveal
Adds a class.
Transition
CSS animates.
Done
Once, then stop.
<div class="sr-grid"> <div class="reveal"><i class="ph-duotone ph-eye"></i><h4>Watch</h4><p>It observes.</p></div> <div class="reveal"><i class="ph-duotone ph-sparkle"></i><h4>Reveal</h4><p>Adds a class.</p></div> <div class="reveal"><i class="ph-duotone ph-arrows-in"></i><h4>Transition</h4><p>CSS animates.</p></div> <div class="reveal"><i class="ph-duotone ph-check"></i><h4>Done</h4><p>Once, then stop.</p></div> </div> <!-- icons come from Phosphor: <script src="https://unpkg.com/@phosphor-icons/web"></script> -->
.sr-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
@media (max-width: 680px) { .sr-grid { grid-template-columns: 1fr 1fr; } }
/* each card: white tile, hidden until it scrolls into view */
.reveal {
background: #fff; color: #1d1d1f; border-radius: 14px; padding: 22px; text-align: center;
opacity: 0; transform: translateY(28px); transition: opacity .6s ease, transform .6s ease;
}
.reveal.in { opacity: 1; transform: none; } /* JS adds .in */
.reveal i { font-size: 1.8rem; color: #0ea5e9; }
.reveal h4 { margin: 8px 0 2px; font-size: .95rem; }
.reveal p { font-size: .78rem; color: #6b7280; margin: 0; }
// add .in when each element scrolls into view
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
});
}, { threshold: 0.2 });
document.querySelectorAll('.reveal').forEach(el => io.observe(el));
Pin a visual (sticky)
Keep one element fixed on screen while the text scrolls past it. This is the backbone of scrollytelling, and it's pure CSS: position: sticky. Scroll inside the frame:
The visual stays.
It's pinned to the top while you scroll.
The story moves.
Captions scroll past the fixed visual.
No JavaScript.
Just position: sticky; top: 0;
<div class="layout">
<div class="pin" role="img" aria-label="A pinned landscape visual"></div>
<div class="story">
<section class="caption"><h4>The visual stays.</h4><p>It's pinned to the top while you scroll.</p></section>
<section class="caption"><h4>The story moves.</h4><p>Captions scroll past the fixed visual.</p></section>
<section class="caption"><h4>No JavaScript.</h4><p>Just <code>position: sticky; top: 0;</code></p></section>
</div>
</div>
.layout { display: grid; grid-template-columns: 1fr 1fr; }
/* the visual: pinned to the top, with a background photo */
.pin {
position: sticky; top: 0; height: 320px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 3.4rem;
background-image:
linear-gradient(rgba(2,6,23,.15), rgba(2,6,23,.45)),
url('https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&q=70');
background-size: cover; background-position: center;
}
/* each caption is a tall block that scrolls past the pinned visual */
.caption {
min-height: 220px; display: flex; flex-direction: column; justify-content: center;
border-bottom: 1px solid #ececef; color: #1d1d1f; padding: 0 22px;
}
.caption:last-child { border-bottom: 0; }
.caption h4 { font-size: 1.15rem; }
.caption p { color: #6b7280; font-size: .88rem; margin-top: 4px; }
Swap the scene as you scroll
The pinned visual changes as each caption arrives — the trick behind those story maps and explainers. IntersectionObserver watches the captions and switches which layer is visible. Scroll the frame:
Mountains
The visual starts here.
Forest
Keep scrolling — it changes.
City
Each scene matches its caption.
<div class="swap-grid">
<div class="stage">
<div class="layer l1 active" role="img" aria-label="Mountains"></div>
<div class="layer l2" role="img" aria-label="Forest"></div>
<div class="layer l3" role="img" aria-label="City"></div>
</div>
<div class="copy">
<div class="step" data-layer="l1"><h4>Mountains</h4><p>The visual starts here.</p></div>
<div class="step" data-layer="l2"><h4>Forest</h4><p>Keep scrolling — it changes.</p></div>
<div class="step" data-layer="l3"><h4>City</h4><p>Each scene matches its caption.</p></div>
</div>
</div>
.swap-grid { display: grid; grid-template-columns: 1fr 1fr; }
/* the stage stays pinned; layers stack on top of each other */
.stage { position: sticky; top: 0; height: 320px; }
.layer {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 3.6rem; color: #fff;
background-size: cover; background-position: center;
opacity: 0; transition: opacity .45s ease;
}
.layer.active { opacity: 1; } /* only the matching layer shows */
/* each layer is its own photo (dark gradient keeps text readable) */
.layer.l1 { background-image: linear-gradient(rgba(2,6,23,.1),rgba(2,6,23,.4)), url('https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=900&q=70'); }
.layer.l2 { background-image: linear-gradient(rgba(2,6,23,.1),rgba(2,6,23,.4)), url('https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=900&q=70'); }
.layer.l3 { background-image: linear-gradient(rgba(2,6,23,.1),rgba(2,6,23,.4)), url('https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=900&q=70'); }
/* the scrolling captions beside the stage */
.copy { padding: 0 22px; }
.step { min-height: 280px; display: flex; flex-direction: column; justify-content: center; color: #1d1d1f; }
.step h4 { font-size: 1.2rem; }
.step p { color: #6b7280; font-size: .88rem; margin-top: 4px; }
// when a caption is in view, show its matching layer
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (!e.isIntersecting) return;
document.querySelectorAll('.layer').forEach(l => l.classList.remove('active'));
document.querySelector('.' + e.target.dataset.layer).classList.add('active');
});
}, { threshold: 0.6 });
document.querySelectorAll('.step').forEach(s => io.observe(s));
Scroll progress bar
A bar that fills as the reader moves through the content — great for long articles. Divide how far you've scrolled by the total scrollable height. Scroll the frame and watch the top bar:
Scroll down and the bar above fills up.
It shows how far through the content you are.
Readers love knowing how much is left.
It's just a little math on the scroll position.
scrollTop ÷ (scrollHeight − clientHeight).
Multiply by 100 for a percentage.
Set that as the bar's width.
You're almost at the bottom now…
And — the bar is full.
<div class="prog-bar"><span id="fill"></span></div> <article class="prog-content"> <p>Scroll down and the bar above fills up.</p> <p>It shows how far through the content you are.</p> <p>Readers love knowing how much is left.</p> <p>It's just a little math on the scroll position.</p> <p>scrollTop ÷ (scrollHeight − clientHeight).</p> <p>Multiply by 100 for a percentage.</p> <p>Set that as the bar's width.</p> <p>You're almost at the bottom now…</p> <p>And — the bar is full. 🎉</p> </article>
.prog-bar { position: sticky; top: 0; height: 6px; background: #e5e5ea; z-index: 2; }
.prog-bar span { display: block; height: 100%; width: 0; background: linear-gradient(90deg, #38bdf8, #818cf8); }
.prog-content { padding: 22px 24px; color: #1d1d1f; }
.prog-content p { color: #52525b; margin-bottom: 14px; font-size: .92rem; }
// fills as the whole page scrolls
const bar = document.getElementById('fill');
window.addEventListener('scroll', () => {
const h = document.documentElement;
const pct = h.scrollTop / (h.scrollHeight - h.clientHeight);
bar.style.width = Math.min(100, pct * 100) + '%';
});
Parallax depth
When the background moves slower than the foreground, the page feels layered and deep. The simplest version is one line of CSS: background-attachment: fixed. Scroll the page and watch the photo stay put behind the text:
<section class="parallax"> <div class="card">The background holds still</div> </section>
.parallax {
height: 260px; border-radius: 12px; overflow: hidden;
position: relative; display: flex; align-items: center; justify-content: center;
background-image: url('https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=1200&q=80');
background-size: cover;
background-position: center;
background-attachment: fixed; /* the parallax effect */
}
.parallax::before { /* dark overlay so text stays readable */
content: ''; position: absolute; inset: 0; background: rgba(0,0,0,.4);
}
.parallax .card {
position: relative; color: #fff; font-size: 1.6rem; font-weight: 800;
letter-spacing: -.02em; text-align: center;
}
background-attachment: fixed can be janky on phones. For smooth, controllable parallax, move a layer with JavaScript: el.style.transform = 'translateY(' + scrollY * 0.3 + 'px)'.CSS scroll-driven animation
Modern browsers can tie an animation straight to scroll position — no JavaScript at all — with
animation-timeline: view(). These cards animate purely from CSS as they enter the viewport (Chrome/Edge today;
older browsers just show them normally):
No JS
Buttery smooth
Tied to scroll
<div class="sda"> <div class="card"><i class="ph-duotone ph-magic-wand"></i><h4>No JS</h4></div> <div class="card"><i class="ph-duotone ph-gauge"></i><h4>Buttery smooth</h4></div> <div class="card"><i class="ph-duotone ph-timer"></i><h4>Tied to scroll</h4></div> </div> <!-- icons come from Phosphor: <script src="https://unpkg.com/@phosphor-icons/web"></script> -->
.sda { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
@media (max-width: 680px) { .sda { grid-template-columns: 1fr; } }
/* each card: white tile with an icon and a label */
.card { background: #fff; color: #1d1d1f; border-radius: 14px; padding: 22px; text-align: center; }
.card i { font-size: 1.7rem; color: #0ea5e9; }
.card h4 { margin: 8px 0 2px; font-size: .95rem; }
@keyframes reveal {
from { opacity: 0; transform: translateY(40px) scale(.92); }
to { opacity: 1; transform: none; }
}
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view(); /* drive it by scroll */
animation-range: entry 0% cover 35%; /* over the first part of entry */
}
}
Horizontal scroll
Sometimes the story moves sideways. A row that scrolls left-to-right — with scroll-snap so cards click into place — is a striking change of pace for galleries, timelines, and case studies. Drag or scroll the row:
Intro
Build
Launch
Grow
Next
<div class="row"> <div class="card c1">01<br>Intro</div> <div class="card c2">02<br>Build</div> <div class="card c3">03<br>Launch</div> <div class="card c4">04<br>Grow</div> <div class="card c5">05<br>Next</div> </div>
.row {
display: flex;
gap: 14px;
overflow-x: auto; /* scroll sideways */
padding-bottom: 12px;
scroll-snap-type: x mandatory;
}
.row > .card {
flex: 0 0 200px; /* fixed width, no shrink */
height: 150px;
border-radius: 14px;
scroll-snap-align: start; /* each card snaps into place */
display: flex; align-items: flex-end; padding: 16px;
color: #fff; font-weight: 800; font-size: 1.05rem; line-height: 1.2;
}
/* a distinct gradient per card */
.card.c1 { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.card.c2 { background: linear-gradient(135deg, #16a34a, #65a30d); }
.card.c3 { background: linear-gradient(135deg, #f59e0b, #ef4444); }
.card.c4 { background: linear-gradient(135deg, #7c3aed, #db2777); }
.card.c5 { background: linear-gradient(135deg, #0891b2, #22d3ee); }
Infinite scroll
Instead of pages, content keeps loading as you near the bottom — the pattern behind social feeds. Watch new items appear as you scroll this box:
<div class="feed" id="feed"> <div id="list"></div> <!-- items get appended here --> <div class="loader" id="loader">Loading more…</div> <div class="end" id="end" style="display:none;">You've reached the end 🎉</div> </div>
.feed { height: 240px; overflow-y: auto; /* its own scroll area */
background: #fff; border-radius: 10px; }
.item {
padding: 13px 18px; border-bottom: 1px solid #ececef;
color: #1d1d1f; font-size: .9rem;
display: flex; align-items: center; gap: 10px;
}
.item .dot {
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #38bdf8, #818cf8);
}
.loader, .end { text-align: center; padding: 16px; color: #6b7280; font-size: .85rem; }
const feed = document.getElementById('feed');
const list = document.getElementById('list');
const loader = document.getElementById('loader');
const end = document.getElementById('end');
let n = 0, MAX = 24, loading = false;
function loadMore(count = 6) { // append the next batch of items
for (let i = 0; i < count && n < MAX; i++) {
n++;
const el = document.createElement('div');
el.className = 'item';
el.innerHTML = '<span class="dot"></span> Item #' + n;
list.appendChild(el);
}
if (n >= MAX) { loader.style.display = 'none'; end.style.display = 'block'; }
}
loadMore(8); // seed the first batch
feed.addEventListener('scroll', () => {
if (loading || n >= MAX) return;
const nearBottom = feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 40;
if (nearBottom) {
loading = true;
setTimeout(() => { loadMore(6); loading = false; }, 300);
}
});
Respect reduced motion
Some people get dizzy or distracted by movement and set “reduce motion” in their OS. Always honor it — keep the content, drop the animation. One media query covers it:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
Putting real photos in the demos
The demos above use icons as stand-ins for visuals. In a real scrollytelling page those are photos —
a pinned product shot, a full-bleed parallax background, or a different image for each scene. Swapping them in is just
an <img> (or a background-image) where the placeholder used to be.
1 · The pinned visual
Drop an image where the pinned element was. It scrolls with position: sticky exactly like the icon did.
<div class="layout"> <img class="pin" src="images/product.jpg" alt="The product"> <div class="scenes">…captions…</div> </div>
.pin {
position: sticky; top: 0;
width: 100%;
aspect-ratio: 4 / 3; /* keep a steady shape */
object-fit: cover; /* fill the frame, crop the rest */
border-radius: 12px;
}
2 · A parallax background
For the full-bleed parallax effect, the photo is a background-image instead of an <img>.
.parallax {
background-image: url('images/hero.jpg');
background-size: cover;
background-position: center;
background-attachment: fixed; /* the parallax effect */
min-height: 420px;
}
3 · A different image per scene
In the scene-swap demo, each layer becomes its own photo. The same .active class fades the matching one in.
<div class="stage"> <img class="layer l1 active" src="images/mountains.jpg" alt="Mountains"> <img class="layer l2" src="images/forest.jpg" alt="Forest"> <img class="layer l3" src="images/city.jpg" alt="City"> </div>
.layer { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; opacity: 0; transition: opacity .4s; }
.layer.active { opacity: 1; } /* JS toggles this as you scroll */
loading="lazy" to any photo that's below the fold. Grab free, high-res shots from Unsplash or Pexels, and always write real alt text. Need sized boxes to test with? See Placeholder Images.Story-driven pages — the common types
“Story-driven” means the page is built as a guided narrative — it reveals one idea at a time as you scroll, with a beginning, middle, and a clear end (usually a call to action). Here are the kinds of pages built this way, and a couple you can go read right now.
Product story
A single product revealed feature by feature as you scroll — pinned visuals, big type, one idea per screen. Apple's product pages are the gold standard.
See Apple AirPodsData explainer
Charts and visuals that animate and annotate themselves as the text walks you through the numbers. The Pudding is full of these “visual essays.”
Browse The PuddingEditorial feature
Long-form journalism with full-bleed photos, video, and parallax woven between paragraphs. The NYT’s “Snow Fall” (2012) kicked off the whole genre.
Brand / about story
A company’s mission and history told as a scroll journey — origin, values, milestones — ending on “join us” or “get started.”
Year in review
Annual reports and “wrapped” recaps that animate stats and highlights one beat at a time. Spotify Wrapped is the famous example.
Case study
A portfolio project told as problem → process → solution → result, with images revealing as each stage arrives. Great for showcasing work.
The narrative shapes they use
Most story pages follow one of a few structures: a straight linear scroll (one continuous journey), chaptered sections (distinct acts with their own visuals), before → after (show the problem, then the change), or problem → solution → proof → CTA (the classic sales narrative). Whichever you pick, give every idea its own space, reveal them in order, and end with one clear next step.
Characteristics & components
The signals that make a page read as scrollytelling:
Story unfolds while scrolling Interactive narrative
Components to build
A sticky pin, a timeline, a scroll-reveal box, and a video tile — live first, then the code.
/* sticky section — pin the visual while captions scroll */
.pin { position: sticky; top: 0; }
/* timeline */
.timeline { position: relative; padding-left: 22px; }
.timeline::before { content:''; position:absolute; left:6px; top:0; bottom:0; width:2px; background:#38bdf8; }
.timeline li::before { content:''; position:absolute; left:-21px; width:10px; height:10px; border-radius:50%; background:#38bdf8; }
/* scroll animation — start hidden, reveal with a class */
.reveal { opacity:0; transform:translateY(26px); transition:.6s cubic-bezier(.25,.8,.25,1); }
.reveal.in { opacity:1; transform:none; }
// reveal + video transitions on scroll
const io = new IntersectionObserver(es => es.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('in'); } // reveal
}), { threshold: .2 });
document.querySelectorAll('.reveal').forEach(el => io.observe(el));
// play a video only while it's on screen
const vidIO = new IntersectionObserver(es => es.forEach(e =>
e.isIntersecting ? e.target.play() : e.target.pause()));
document.querySelectorAll('video').forEach(v => vidIO.observe(v));
The scrollytelling toolkit
Sticky pins a visual, IntersectionObserver reacts when things enter the screen, CSS transitions make the motion smooth, and scroll-driven animations are the JS-free future. Combine them to reveal, pin, swap, and track progress — then always check it with reduced motion on. Related: Apple-Style Design · Interactive Effects.