Design & Media

Scrollytelling

Scroll-driven storytelling: as the reader scrolls, the page responds — content fades in, visuals pin in place, scenes swap, and progress fills. It turns a flat page into a guided experience. Every demo below is live — scroll it.

The idea

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.

Golden rule: motion should serve the story — reveal one idea at a time, guide the eye, and always respect users who prefer reduced motion (last section).
Technique 1

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.

HTML
<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> -->
CSS
.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; }
JavaScript
// 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));
Technique 2

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;

↕ Scroll inside the frame
HTML
<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>
CSS
.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; }
Technique 3

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.

↕ The background swaps to match the caption
HTML
<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 &mdash; it changes.</p></div>
    <div class="step" data-layer="l3"><h4>City</h4><p>Each scene matches its caption.</p></div>
  </div>
</div>
CSS
.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; }
JavaScript
// 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));
Technique 4

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.

HTML
<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 &divide; (scrollHeight &minus; 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>
CSS
.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; }
JavaScript
// 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) + '%';
});
Technique 5

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:

The background holds still
HTML
<section class="parallax">
  <div class="card">The background holds still</div>
</section>
CSS
.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;
}
Heads up: 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)'.
Technique 6 · New

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

HTML
<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> -->
CSS
.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 */
  }
}
Technique 7

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:

01
Intro
02
Build
03
Launch
04
Grow
05
Next
↔ Scroll sideways
HTML
<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>
CSS
.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); }
Tip: keep a normal vertical scroll available too — pure horizontal-only pages can confuse people and trackpads. A horizontal section inside a vertical page is the friendlier pattern.
Technique 8

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:

Loading more…
HTML
<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>
CSS
.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; }
JavaScript
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);
  }
});
Heads up: infinite scroll can trap people away from the footer and is hard with a keyboard. Pair it with a “Load more” button or a clear end state — like this demo, which stops after a set number.
Do it right

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;
  }
}
Also: never hide content behind an animation that might not run. Reveal-on-scroll elements should still be readable if the JavaScript fails — start them visible and let JS add the hidden state, or test carefully.
Add your images

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.

Before — an icon placeholder
Misty mountain range at dawn
After — a real photo, same box

1 · The pinned visual

Drop an image where the pinned element was. It scrolls with position: sticky exactly like the icon did.

HTML
<div class="layout">
  <img class="pin" src="images/product.jpg" alt="The product">
  <div class="scenes">…captions…</div>
</div>
CSS
.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>.

CSS
.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.

HTML
<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>
CSS
.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 */
Performance matters here: scrollytelling shows big images, so optimize them — resize to the size they'll display, compress, and add 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.
In the wild

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 AirPods

Data 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 Pudding

Editorial 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.

Reference

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.

📌 Pinned visual
position: sticky
Act 1
Act 2
Act 3
Fades in on scroll
CSS
/* 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; }
JavaScript
// 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));
Recap

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.