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
/* 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 */
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)
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
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
Pattern: Staggered reveal (view timeline per item)
Scroll inside - each card reveals independently with a scale + fade as it enters
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?
Question 2
What's the difference between scroll() and view()?
Question 3
What does animation-range: entry 0% entry 100% mean?
Question 4
Why are CSS scroll-driven animations more performant than JavaScript scroll handlers?
Question 5
scroll(self) means: