Journal

Forget Parallax: Building a Native CSS Narrative Engine with Scroll Timelines

Back to Blog
May 29, 2026
Blog
Forget Parallax: Building a Native CSS Narrative Engine with Scroll Timelines

Scroll-driven animations just went cross-browser. Here’s how to replace 10KB of JS with a few lines of CSS, and build real narrative storytelling into your layouts.


The Scroll Library Tax

If you’ve ever shipped a “scrollytelling” experience to production, you know the drill. You drop in ScrollMagic, GSAP ScrollTrigger, or Intersection Observer boilerplate. You manage scroll event throttling. You fight z-index stacking in the middle of a 300-line JavaScript file that only three people on your team understand. And when you’re done, you’ve shipped 12-18KB of gzipped JavaScript — just to move a few elements around while the user scrolls.

The results are brittle. Repaints on scroll cause jank. Mobile browsers treat scroll listeners as a second-class citizen. And every time the design team wants to adjust a timing curve or swap an image sequence, it’s back into the JS file.

What if the browser itself could handle all of this?

As of early 2026, it can. The CSS animation-timeline, view-timeline, and scroll-timeline properties are now supported in Chrome, Firefox, Safari, and Edge. The era of JavaScript scroll engines is winding down.

What Are Scroll-Driven Animations?

The concept is deceptively simple. Instead of driving an animation from a clock (animation-duration: 2s), you drive it from scroll progress. The animation’s 0% corresponds to the user being at the top of the scroll port (or the element entering the viewport), and 100% corresponds to the bottom (or the element leaving).

CSS 2025 introduced three key primitives:

scroll-timeline — The page-level scroll

@scroll-timeline page-scroll {
  source: auto;
  orientation: block;
  scroll-offsets: 0%, 100%;
}

.element {
  animation: fade-in 1s linear;
  animation-timeline: page-scroll;
}

This maps the document’s scroll progress directly to the animation. At the top of the page, the element is invisible. At the bottom, it’s fully visible. Simple, and remarkably powerful.

view-timeline — The element-level scroll

This is the one you’ll reach for most often in narrative layouts. Instead of tracking the document, you track when a specific element enters and exits the viewport:

.story-panel {
  view-timeline-name: --panel;
  view-timeline-axis: block;
}

.story-panel .title {
  animation: slide-up 1s linear;
  animation-timeline: view(--panel);
  animation-range: entry 0% entry 100%;
}

The element defines a timeline via view-timeline-name, and its children (or other elements referencing that timeline) animate based on how much of the parent is visible. The animation-range property lets you choose the exact window: “animate from the moment the element starts entering the viewport until it’s fully entered.”

animation-range — Fine-grained control

Where traditional keyframes run from 0% to 100% in time, scroll-driven keyframes run from 0% to 100% of a scroll range. The animation-range property lets you pick that window:

.element {
  animation: fade 1s linear;
  animation-timeline: view();
  animation-range: entry 10% exit 80%;
}

This means: “Start fading when the element is 10% into the viewport, and finish fading when it’s 80% past the viewport exit.” You get precise control over when things begin and end relative to the scroll position.

Grammar of Narrative Layouts

Let’s move past single-element demos. The real power shows when you compose multiple scroll-driven animations into a story.

Think about a typical narrative page:

  1. Chapter 1: A hero image zooms out and a headline fades in
  2. Chapter 2: A sticky visual stays fixed while supporting text scrolls past
  3. Chapter 3: A data visualization animates its bars as it enters view
  4. Chapter 4: A reveal — the final image expands to full-viewport height

With scroll timelines, this becomes a pure CSS composition.

<article class="story">
  <section class="chapter" id="intro">
    <figure class="chapter-visual">
      <img src="hero.jpg" alt="" />
    </figure>
    <div class="chapter-text">
      <h2>The Descent</h2>
      <p>It began in the observatory at dusk...</p>
    </div>
  </section>

  <section class="chapter chapter--sticky" id="sticky-scene">
    <div class="sticky-visual">
      <div class="progress-ring"></div>
    </div>
    <div class="scroll-past">
      <p>Paragraph one — explains the setup...</p>
      <p>Paragraph two — introduces tension...</p>
      <p>Paragraph three — the twist...</p>
    </div>
  </section>

  <section class="chapter" id="data-reveal">
    <div class="chart">
      <div class="bar bar--1"></div>
      <div class="bar bar--2"></div>
      <div class="bar bar--3"></div>
    </div>
  </section>
</article>

Chapter 1: The hero zoom

#intro .chapter-visual {
  view-timeline-name: --hero;
}

#intro img {
  animation: hero-zoom 1s linear;
  animation-timeline: view(--hero);
  animation-range: entry 0% entry 100%;
}

#intro h2 {
  animation: text-rise 0.6s ease-out;
  animation-timeline: view(--hero);
  animation-range: entry 20% entry 100%;
}

@keyframes hero-zoom {
  from { scale: 1.5; filter: blur(8px); opacity: 0.4; }
  to   { scale: 1; filter: blur(0); opacity: 1; }
}

@keyframes text-rise {
  from { translate: 0 2rem; opacity: 0; }
  to   { translate: 0 0; opacity: 1; }
}

The image enters faded and blurred, then sharpens. The headline follows 20% later, creating a staggered reveal — no JS timing logic required.

Chapter 2: The sticky narrative engine

This is where CSS scroll timelines truly shine. You can create a “sticky parent” whose children animate in sequence as the user scrolls past. The children are fixed-position within the sticky container, and each activates at a different scroll offset:

.chapter--sticky {
  view-timeline-name: --sticky-chapter;
  height: 300vh; /* three viewports of scroll room */
}

.scroll-past p {
  animation: fade-advance 1s linear;
  animation-timeline: view(--sticky-chapter);
  animation-range: cover 0% cover 33%;
}

.scroll-past p:nth-child(2) {
  animation-range: cover 33% cover 66%;
}

.scroll-past p:nth-child(3) {
  animation-range: cover 66% cover 100%;
}

@keyframes fade-advance {
  0%   { opacity: 0; translate: 0 1rem; }
  100% { opacity: 1; translate: 0 0; }
}

Each paragraph becomes visible and slides up in sequence, one per third of the scroll journey through the section. Combined with position: sticky on the visual element, you get the classic NYT-style snow fall effect — but the browser handles every frame, no rAF overhead.

Chapter 3: Data revealed on scroll

#data-reveal {
  view-timeline-name: --chart-enter;
}

.bar {
  animation: bar-grow 1s ease-out;
  animation-timeline: view(--chart-enter);
  animation-range: entry 0% entry 100%;
  transform-origin: bottom;
}

.bar--1 { animation-delay: 0s; }
.bar--2 { animation-delay: 0.2s; }
.bar--3 { animation-delay: 0.4s; }

@keyframes bar-grow {
  from { scale: 1 0; }
  to   { scale: 1 1; }
}

Staggered bar charts, revealing data as the user scrolls — no Intersection Observer, no frameloop, no math.

Practical Considerations

The scrollport chain

A common gotcha: scroll-timeline and view-timeline only work within the nearest scrollport that has overflow other than visible. If you have nested scroll containers, you may need to explicitly set scroll-timeline on the correct ancestor. Debug this by checking DevTools → Animations → Scroll Timeline (available in Chrome since 2024).

Compositor threading

The biggest performance win is invisible. Scroll-driven animations run on the compositor thread, not the main thread. This means even on mid-range Android phones or older laptops, your narrative keeps running at 120fps while the main thread is busy parsing JSON or garbage collecting. You aren’t just reducing code size — you’re eliminating the root cause of scroll jank.

Accessibility

Scroll-driven animations respect prefers-reduced-motion. If a user has reduced motion enabled, the animation jumps to its endpoint immediately:

@media (prefers-reduced-motion: no-preference) {
  .element {
    animation: fade-in 1s linear;
    animation-timeline: view();
  }
}

@media (prefers-reduced-motion: reduce) {
  .element {
    opacity: 1;
    translate: 0 0;
  }
}

Always set the final keyframe state as the default CSS, so the content is visible regardless of animation support.

Browser Support & Progressive Enhancement

Here’s the state as of May 2026:

Browser Scroll Timelines View Timelines
Chrome 130+
Firefox 128+
Safari 18.2+
Edge 130+

All major engines now support the core spec. Safari was the last to ship, with 18.2 in early 2026.

The progressive enhancement strategy is straightforward:

/* Base: visible by default */
.reveal-panel {
  opacity: 1;
  translate: 0 0;
}

/* Enhanced: scroll-driven animation */
@supports (animation-timeline: scroll()) {
  .reveal-panel {
    view-timeline-name: --panel;
    animation: reveal 1s linear;
    animation-timeline: view(--panel);
    animation-range: entry 0% entry 100%;
  }

  @keyframes reveal {
    from { opacity: 0; translate: 0 3rem; }
    to   { opacity: 1; translate: 0 0; }
  }
}

Users on older browsers see the content readably laid out. Users on modern browsers get the narrative experience. No polyfills needed.

If you need to support the last 5% of users on very old Safari (pre-18.2) or Chrome (pre-130), you can layer Intersection Observer as a fallback — but honestly, at this point, the API is safer to depend on than shipping a JavaScript scroll framework for the next project.

What Falls Away

Once you internalize scroll-driven animations, a lot of old patterns become obsolete:

The Takeaway

Scroll-driven animations aren’t a CSS novelty. They’re a fundamental shift in how we think about scroll-based interactivity. The browser now understands scroll progress as a first-class animation driver. That means less JavaScript, fewer bugs, better performance, and more time spent on the actual narrative instead of the scaffolding.

For your next storytelling project — a product landing page, a data essay, a longform article — try this:

  1. Write your HTML semantically, section by section
  2. Add view-timeline-name to each narrative chapter
  3. Define keyframes for entry effects (fade, scale, blur, translate)
  4. Wire them up with animation-timeline: view(--name) and animation-range
  5. Let the browser handle the rest

You’ll ship less code. Your animations will run smoother. And your team will thank you when the design changes and all you need to tweak is a @keyframes rule instead of a scroll listener chain.

The CSS timeline API is the closest the platform has ever given us to a declarative scroll narrative engine. It’s time to stop fighting scroll and start telling stories with it.


Want to see this in action? The full interactive demo with live code editor is available at codepen.io/collection/scroll-driven-narratives.

Browser compatibility reference: caniuse.com/css-scroll-driven-animations

Tags

Design Trends
Frontend Development
UI Design
Web Design
Web Development