← Back to Index

Lesson 23

Scroll-Driven Animations - Replacing JavaScript with CSS Timelines

Notice the blue progress bar at the top of this page? It's tracking your scroll position - and it's pure CSS. No JavaScript, no IntersectionObserver, no requestAnimationFrame. That's scroll-driven animations in action.

This API replaces an entire category of JavaScript libraries (ScrollMagic, GSAP ScrollTrigger, AOS) with native CSS. It builds on the @keyframes you already know from Lesson 7 - the only change is what drives the animation forward: scroll position instead of time.

The mental model: two types of scroll timeline

Normal CSS animation: progress goes from 0% to 100% as time passes (e.g. over 2 seconds).

Scroll-driven animation: progress goes from 0% to 100% as the user scrolls. No duration needed - the scroll position IS the progress.

There are two timeline types:

scroll() - tied to a scroll container's overall scroll progress (0% = top, 100% = bottom). Use for: progress bars, parallax, background effects.

view() - tied to an element's visibility within the scrollport (0% = element enters, 100% = element exits). Use for: reveal animations, fade-ins, staggered content.

scroll() - scroll progress timeline

Example: reading progress bar (this page!)

The blue bar at the top of this page is driven by this CSS - scroll to see it grow

Look at the top of the page - the blue bar grows as you scroll down. It's the same code shown below.

/* CSS — that's it. No JS. */
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: blue;
  transform-origin: left;
  transform: scaleX(0);

  /* The magic: bind a keyframes animation to scroll progress */
  animation: grow-progress linear;
  animation-timeline: scroll();
}

@keyframes grow-progress {
  to { transform: scaleX(1); }
}

/* How it reads:
   - animation: grow-progress linear → standard animation, linear easing
   - animation-timeline: scroll() → but driven by scroll, not time
   - scroll() with no args → uses nearest ancestor scroll container (here: the page)
   - The keyframes go from scaleX(0) to scaleX(1) as scroll goes from 0% to 100% */

What animation-timeline: scroll() does: It replaces the time-based timeline with a scroll-based one. The animation no longer needs a duration - scroll position IS the progress.

Why linear easing: The easing function controls how scroll progress maps to keyframe progress. linear gives a 1:1 relationship (50% scrolled = 50% through animation). You can use other easings, but for scroll-driven effects, non-linear easing makes the animation feel disconnected from the user's scrolling.

Example: progress bar in a scroll container (not the page)

Scroll inside this box - the blue bar tracks progress within this container only

Scroll me! The blue bar above tracks how far you've scrolled within this container specifically - not the page.

Keep scrolling...

Almost there...

Done! The bar should be full.

/* The indicator bar tracks THIS container's scroll progress */
.indicator {
  position: sticky;
  top: 0;
  height: 4px;
  background: blue;
  transform-origin: left;
  transform: scaleX(0);
  animation: grow-progress linear;
  animation-timeline: scroll(nearest);  /* nearest scrolling ancestor */
}

/* scroll() arguments:
   scroll()         → nearest ancestor scroll container
   scroll(self)     → this element IS the scroll container
   scroll(root)     → the document root scroller
   scroll(nearest)  → nearest ancestor (same as default) */

scroll() arguments are keywords, not element references. You can't pass in a specific element. The keywords describe which scroll container relative to the animated element:

scroll() / scroll(nearest) - walks up the DOM until it finds an ancestor with overflow: scroll/auto

scroll(self) - the animated element itself is the scroller

scroll(root) - the document root (page scroll, always)

If you need to target a specific scroll container that isn't an ancestor, use a named timeline instead.

view() - view progress timeline

Scroll inside this box - items fade in as they enter the visible area

I fade in as I enter the viewport
Me too - each item animates independently
The animation is tied to MY visibility, not the scroll position
No IntersectionObserver needed
Last one!
/* The reveal animation */
@keyframes fade-slide-in {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Apply to each item */
.reveal-item {
  animation: fade-slide-in linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

/* How it reads:
   - animation-timeline: view() → tied to this element's visibility
   - animation-range: entry 0% entry 100%
     → animate from "element just entering" to "element fully entered"
   - "both" fill mode keeps the end state after the animation completes */
Ad

Understanding animation-range

/* Named ranges for view() timeline:

   cover    → full journey: first pixel enters to last pixel exits
   contain  → element is fully visible (both edges inside scrollport)
   entry    → element is entering (leading edge enters → trailing edge enters)
   exit     → element is exiting (leading edge exits → trailing edge exits)

   Usage: animation-range: <start-range> <start-%> <end-range> <end-%>
*/

/* Animate only during entry (fade in as it appears) */
animation-range: entry 0% entry 100%;

/* Animate during the full journey (parallax-style, always moving) */
animation-range: cover 0% cover 100%;

/* Animate only during exit (fade out as it leaves) */
animation-range: exit 0% exit 100%;

/* Start at 25% through entry, end at 75% through entry (compressed) */
animation-range: entry 25% entry 75%;

The mental model for view() ranges: Think of the element's journey through the scrollport as a timeline with named chapters:

1. entry - element slides in from the edge (partially visible, growing)

2. contain - element is fully inside the scrollport

3. exit - element slides out the other edge (partially visible, shrinking)

cover spans the entire journey (entry + contain + exit). You pick which chapter(s) your animation plays during.

Parallax effects

Scroll inside - the text in the blue area shifts at a different rate (parallax)

PARALLAX

Scroll down - the text in the blue header moves at a different rate than this content. It shifts upward, shrinks, and fades.

The effect is exaggerated here so you can clearly see it.

This is 100% CSS - no JavaScript.

/* The text inside shifts at a slower rate via scroll-driven animation */
.parallax-bg h2 {
  animation: parallax-shift linear;
  animation-timeline: scroll(nearest);
}

@keyframes parallax-shift {
  from { transform: translateY(0); }
  to   { transform: translateY(-30px); }
  /* Moves 30px upward over the full scroll range —
     slower than the content, creating the parallax effect */
}

What this replaces

/* BEFORE (JavaScript) */
// Reading progress bar
window.addEventListener('scroll', () => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  bar.style.transform = `scaleX(${progress})`;
});

// Reveal on scroll (IntersectionObserver)
const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) entry.target.classList.add('visible');
  });
});
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));

/* AFTER (CSS only — all three effects) */
.progress-bar { animation: grow linear; animation-timeline: scroll(); }
.reveal       { animation: fade-in linear both; animation-timeline: view(); animation-range: entry; }
.parallax-bg  { animation: shift linear; animation-timeline: scroll(); }

Performance benefit: JavaScript scroll handlers run on the main thread and can cause jank. Scroll-driven CSS animations run on the compositor thread - the same thread that handles CSS transforms and opacity. They're guaranteed 60fps regardless of main thread load.

The full API at a glance

/* === Properties === */
animation-timeline: scroll() | view() | auto | none | <custom-name>;
animation-range: normal | <range-start> <range-end>;

/* === scroll() function === */
scroll()              /* nearest scroll ancestor, block axis */
scroll(self)          /* this element is the scroller */
scroll(root)          /* document root scroller */
scroll(nearest)       /* nearest ancestor (default) */
scroll(self inline)   /* horizontal scrolling */

/* === view() function === */
view()                /* default: block axis, no inset */
view(inline)          /* track horizontal visibility */
view(block 50px)      /* add 50px inset (shrink the trigger zone) */

/* === animation-range values === */
animation-range: cover;               /* full element journey (default for view) */
animation-range: entry;               /* just the entry phase */
animation-range: exit;                /* just the exit phase */
animation-range: contain;             /* while fully visible */
animation-range: entry 0% entry 100%; /* explicit start/end within a range */

/* === Named timelines (for targeting a specific ancestor) === */
.scroller {
  scroll-timeline-name: --page-scroll;
  scroll-timeline-axis: block;
}
.animated-child {
  animation-timeline: --page-scroll;  /* explicitly reference that scroller */
}

/* === View timeline on a specific subject === */
.subject {
  view-timeline-name: --card-visibility;
  view-timeline-axis: block;
}
.related-element {
  animation-timeline: --card-visibility;  /* animate based on .subject's visibility */
}

Practical patterns

Pattern: Shrinking sticky header

Scroll inside - the header shrinks as you scroll down

Site Header

Scroll down to see the header shrink. It only animates over the first 30% of scroll, then stays small.

Keep scrolling...

The header stays compact now.

/* Header shrinks as you scroll down the page */
.site-header {
  position: sticky;
  top: 0;
  animation: shrink-header linear both;
  animation-timeline: scroll();
  animation-range: 0% 30%;  /* only animate over first 30% of scroll */
}

@keyframes shrink-header {
  from { padding-block: 1.5rem; font-size: 1.2rem; }
  to   { padding-block: 0.5rem; font-size: 0.85rem; }
}

Pattern: Horizontal scroll-driven progress (carousel indicator)

Scroll horizontally - the indicator bar tracks carousel progress

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5

Pattern: Staggered reveal (view timeline per item)

Scroll inside - each card reveals independently with a scale + fade as it enters

Card 1 - I reveal on entry
Card 2 - independent timeline
Card 3 - no JS needed
Card 4 - each tracks its own visibility
Card 5 - last one!

Browser support and progressive enhancement

/* Feature detection — provide fallback for unsupported browsers */
@supports (animation-timeline: scroll()) {
  .progress-bar {
    animation: grow-progress linear;
    animation-timeline: scroll();
  }
}

/* Or just let it degrade gracefully:
   - Without support, animation-timeline is ignored
   - The element stays in its initial state (no animation plays)
   - This is usually acceptable — scroll effects are enhancement, not functionality */

As of mid-2026: Chrome 115+, Edge 115+, Firefox 110+ (full support), Safari 18.4+.

Retrieval check

Question 1

What does animation-timeline: scroll() do?

Triggers the animation when the user starts scrolling
Replaces the time-based timeline with scroll progress (0% at top, 100% at bottom)
Makes the animation play faster when scrolling faster
Pauses the animation until the element is scrolled into view

Question 2

What's the difference between scroll() and view()?

scroll() is for vertical, view() is for horizontal
scroll() tracks a container's overall scroll progress; view() tracks an element's visibility within the scrollport
scroll() uses JavaScript, view() is pure CSS
They're identical but view() has better browser support

Question 3

What does animation-range: entry 0% entry 100% mean?

The animation plays during the first 100% of scroll
The animation plays from when the element starts entering to when it's fully entered
The animation has a 100% entry delay before playing
Only the first 100px of the element triggers the animation

Question 4

Why are CSS scroll-driven animations more performant than JavaScript scroll handlers?

CSS is always faster than JavaScript by definition
They use WebGL for rendering
They run on the compositor thread (like transforms/opacity), not the main thread
They skip the rendering pipeline entirely

Question 5

scroll(self) means:

The element scrolls itself into view
This element IS the scroll container (track its own scroll progress)
Only this element's animations are affected
The scroll timeline is scoped to this component only