CSS Scroll-Driven Animations: Complete Guide to scroll() and view()
Table of Contents
- Introduction: What Are Scroll-Driven Animations?
- Scroll Timelines with scroll()
- View Timelines with view()
- Controlling Animation Range
- Practical Examples
- Scroll-Linked vs Time-Based Animations
- Named Scroll Timelines
- Named View Timelines
- Performance Advantages
- Browser Support and Progressive Enhancement
- Complete Real-World Examples
- Frequently Asked Questions
1. Introduction: What Are Scroll-Driven Animations?
Scroll-driven animations let you tie CSS animation progress directly to scroll position -- no JavaScript required. Instead of running on a clock, the animation advances as the user scrolls. Scroll up, and the animation rewinds. Stop scrolling, and it freezes in place.
Before this API existed, developers relied on JavaScript scroll event listeners, Intersection Observer callbacks, or libraries like GSAP ScrollTrigger to connect scroll position to visual effects. All of those approaches run on the main thread, competing with layout, paint, and other JavaScript for CPU time.
The CSS Scroll-Driven Animations specification introduces two new timeline types:
- Scroll Progress Timeline (
scroll()) -- tracks how far a scroll container has been scrolled, from 0% to 100%. - View Progress Timeline (
view()) -- tracks an element's visibility as it enters, crosses, and exits a scrollport.
Both timelines plug into the existing CSS @keyframes system. You write a normal keyframe animation, then swap the default time-based timeline for a scroll-driven one using the animation-timeline property. Everything else -- easing, fill modes, delays -- works exactly as before.
Key insight: Scroll-driven animations run on the compositor thread, completely off the main thread. This means zero jank, zero layout thrashing, and buttery-smooth 60fps animations regardless of how busy your JavaScript is.
2. Scroll Timelines with scroll()
A scroll progress timeline maps a scroll container's scroll range to animation progress. When the container is scrolled to the top, the animation is at 0%. When scrolled to the bottom, it is at 100%.
Basic Syntax
/* Define a normal keyframe animation */
@keyframes progress-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* Attach it to a scroll timeline */
.progress-bar {
animation: progress-fill linear both;
animation-timeline: scroll();
}
The scroll() function accepts two optional arguments:
animation-timeline: scroll(<scroller> <axis>);
/* scroller: which scroll container to track */
/* nearest -- closest ancestor scroll container (default) */
/* root -- the document viewport */
/* self -- the element itself (if it scrolls) */
/* axis: which scroll axis to track */
/* block -- block axis, i.e. vertical in LTR (default) */
/* inline -- inline axis, i.e. horizontal in LTR */
/* x -- always horizontal */
/* y -- always vertical */
Examples with Different Scrollers
/* Track the document scroll (page-level progress bar) */
.page-progress {
animation: progress-fill linear both;
animation-timeline: scroll(root block);
}
/* Track a specific scrollable container */
.panel-progress {
animation: progress-fill linear both;
animation-timeline: scroll(nearest block);
}
/* Track horizontal scroll */
.horizontal-indicator {
animation: progress-fill linear both;
animation-timeline: scroll(root inline);
}
Here is how the scroll timeline maps position to progress:
Scroll Position Animation Progress
──────────────────────────────────────────────
top of container ──► 0% (animation start)
| |
| scrolling... | animating...
| |
bottom of container ──► 100% (animation end)
The mapping is linear by default.
The animation-timing-function still applies
to the keyframe interpolation within that range.
3. View Timelines with view()
A view progress timeline tracks an element's visibility within a scroll container. The animation progresses as the subject element moves through the scrollport -- from the moment it first appears to the moment it fully disappears.
Basic Syntax
@keyframes fade-in {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fade-in linear both;
animation-timeline: view();
}
The view() function accepts two optional arguments:
animation-timeline: view(<axis> <inset>);
/* axis: same as scroll() -- block, inline, x, y */
/* block is the default */
/* inset: shrinks the viewable area */
/* auto -- no adjustment (default) */
/* <length-percentage> -- single value for both */
/* <start> <end> -- different start/end insets */
Using Insets to Adjust the Trigger Zone
/* Start animation 100px inside the viewport edge */
.card {
animation: fade-in linear both;
animation-timeline: view(block 100px);
}
/* Different inset for top and bottom */
.card {
animation: fade-in linear both;
animation-timeline: view(block 50px 200px);
/* 50px inset at start, 200px inset at end */
}
Visually, here is what happens as you scroll an element through the viewport:
┌─────────────────────────┐
│ VIEWPORT │
│ │
│ ┌───────────────────┐ │ ◄─ element fully visible
│ │ .card │ │ (contain range)
│ └───────────────────┘ │
│ │
└─────────────────────────┘
ABOVE viewport: element not yet visible
────────────────────────────────────────
entry range: element sliding into view
contain range: element fully inside viewport
exit range: element sliding out of view
────────────────────────────────────────
BELOW viewport: element no longer visible
4. Controlling Animation Range
By default, a view timeline animation runs across the entire visibility range of an element -- from first pixel visible to last pixel gone. The animation-range property lets you pick a narrower window.
Named Ranges for view()
/* Available range names for view timelines:
*
* cover -- from first visible pixel to last visible pixel
* (this is the default full range)
*
* contain -- from fully entered to starting to exit
* (element completely inside viewport)
*
* entry -- from first visible pixel to fully entered
*
* exit -- from starting to exit to fully gone
*
* entry-crossing -- while crossing the entry edge
*
* exit-crossing -- while crossing the exit edge
*/
/* Animate only while the element enters the viewport */
.fade-on-enter {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
/* Animate only while the element is fully visible */
.glow-while-visible {
animation: glow-pulse linear both;
animation-timeline: view();
animation-range: contain 0% contain 100%;
}
/* Animate during exit only */
.fade-on-exit {
animation: fade-out linear both;
animation-timeline: view();
animation-range: exit 0% exit 100%;
}
Combining Range Start and End Points
/* Start at 25% through entry, end at 75% through cover */
.card {
animation: slide-up linear both;
animation-timeline: view();
animation-range: entry 25% cover 75%;
}
/* Use pixel offsets instead of percentages */
.card {
animation: slide-up linear both;
animation-timeline: view();
animation-range: entry 20px exit -20px;
}
This diagram shows where each named range falls:
Scroll direction ──►
┌──────────┬────────────┬──────────────┬────────────┬──────────┐
│ not │ entry │ contain │ exit │ not │
│ visible │ 0%──100% │ 0%──100% │ 0%──100% │ visible │
└──────────┴────────────┴──────────────┴────────────┴──────────┘
◄─────────────────── cover 0% ──────────────── cover 100% ────►
Shorthand: animation-range is shorthand for animation-range-start and animation-range-end. Use the longhand properties when you need to set them independently, such as in responsive overrides.
5. Practical Examples
Reading Progress Bar
/* HTML: <div class="progress-bar"></div> at top of page */
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: #3b82f6;
transform-origin: left;
animation: grow-width linear both;
animation-timeline: scroll(root);
z-index: 1000;
}
@keyframes grow-width {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Parallax Background Effect
.hero {
position: relative;
height: 100vh;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: -20% 0; /* extra height for movement room */
background: url('/images/hero.jpg') center/cover;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
@keyframes parallax-shift {
from { transform: translateY(-10%); }
to { transform: translateY(10%); }
}
Fade-In Cards on Scroll
.card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes card-reveal {
from {
opacity: 0;
transform: translateY(60px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
Horizontal Reveal Animation
.slide-in-left {
animation: slide-from-left linear both;
animation-timeline: view();
animation-range: entry 0% entry 80%;
}
@keyframes slide-from-left {
from {
opacity: 0;
transform: translateX(-100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
Experiment with CSS Animations Visually
Use our CSS Animation Playground to test keyframes, timing functions, and animation properties in real time.
Open Animation Playground6. Scroll-Linked vs Time-Based Animations
You can apply multiple animations to a single element -- some driven by scroll, others driven by time. CSS comma-separates animation values, and each animation can reference a different timeline.
@keyframes fade-in-scroll {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes subtle-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(59,130,246,0.3); }
50% { box-shadow: 0 0 0 12px rgba(59,130,246,0); }
}
.card {
/* First animation: scroll-driven fade in */
/* Second animation: time-based pulse loop */
animation:
fade-in-scroll linear both,
subtle-pulse 2s ease-in-out infinite;
/* Only the first animation gets a scroll timeline */
/* The second automatically uses the default time timeline */
animation-timeline: view(), auto;
animation-range: entry 0% entry 100%, normal;
}
The key rule: each value in a comma-separated animation list pairs with the corresponding value in animation-timeline. Use auto to keep the default document timeline (clock-based) for any animation in the list.
Overriding Timeline Per Animation
/* animation-name pairs with animation-timeline by index */
.element {
animation-name: scroll-effect, time-effect, another-scroll;
animation-duration: auto, 1.5s, auto;
animation-timeline: scroll(root), auto, view();
animation-fill-mode: both, none, both;
animation-iteration-count: 1, infinite, 1;
}
/* scroll-effect uses scroll(root) */
/* time-effect uses auto (normal clock) */
/* another-scroll uses view() */
7. Named Scroll Timelines
The anonymous scroll() function tracks the nearest ancestor scroller. But what if the animation target is not a descendant of the scroll container you want to track? Named scroll timelines solve this by letting you define a timeline on one element and reference it from another.
/* Step 1: Name a timeline on the scroll container */
.sidebar {
overflow-y: auto;
scroll-timeline-name: --sidebar-scroll;
scroll-timeline-axis: block;
}
/* Step 2: Reference the named timeline on any element */
.sidebar-progress {
animation: progress-fill linear both;
animation-timeline: --sidebar-scroll;
}
@keyframes progress-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Shorthand Property
/* scroll-timeline is shorthand for name + axis */
.sidebar {
overflow-y: auto;
scroll-timeline: --sidebar-scroll block;
}
Multiple Named Timelines
/* You can define multiple named timelines */
.scroll-container {
overflow: auto;
scroll-timeline:
--vertical-progress block,
--horizontal-progress inline;
}
/* Different elements can reference different timelines */
.vertical-bar {
animation: fill-bar linear both;
animation-timeline: --vertical-progress;
}
.horizontal-bar {
animation: fill-bar linear both;
animation-timeline: --horizontal-progress;
}
Naming convention: Named timelines use dashed-ident syntax, like custom properties. They always start with -- (e.g., --my-timeline). This avoids collisions with future CSS keywords.
8. Named View Timelines
Just as you can name scroll timelines, you can name view timelines. This is powerful for choreographing animations across elements that are not direct siblings.
/* Step 1: Name a view timeline on the tracked element */
.hero-image {
view-timeline-name: --hero-visibility;
view-timeline-axis: block;
}
/* Step 2: Animate other elements based on hero visibility */
.hero-caption {
animation: caption-reveal linear both;
animation-timeline: --hero-visibility;
animation-range: contain 0% contain 50%;
}
.hero-overlay {
animation: overlay-fade linear both;
animation-timeline: --hero-visibility;
animation-range: exit 0% exit 100%;
}
@keyframes caption-reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes overlay-fade {
from { background: transparent; }
to { background: rgba(0,0,0,0.7); }
}
Choreographing a Multi-Element Sequence
/* The section itself defines the view timeline */
.feature-section {
view-timeline: --feature-view block;
}
/* Title enters first */
.feature-section .title {
animation: slide-up linear both;
animation-timeline: --feature-view;
animation-range: entry 0% entry 40%;
}
/* Description enters second */
.feature-section .description {
animation: slide-up linear both;
animation-timeline: --feature-view;
animation-range: entry 20% entry 60%;
}
/* Image enters last */
.feature-section .image {
animation: slide-up linear both;
animation-timeline: --feature-view;
animation-range: entry 40% entry 80%;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(50px); }
to { opacity: 1; transform: translateY(0); }
}
This creates a staggered entrance effect: the title fades in first, then the description, then the image -- all driven entirely by scroll position with no JavaScript timing code.
9. Performance Advantages
CSS scroll-driven animations offer a significant performance advantage over every JavaScript-based alternative. Here is why:
- Compositor thread: Scroll-driven animations for
transformandopacityrun entirely on the compositor thread, the same thread that handles native scrolling. The main thread is never involved. - No scroll event listeners: JavaScript
scrollevents fire on the main thread at variable rates. They can cause jank if the handler triggers layout recalculations. - No requestAnimationFrame overhead: rAF callbacks compete with other main thread work. During heavy JavaScript execution, rAF-driven scroll animations stutter.
- Better than Intersection Observer: IO is efficient for detecting visibility thresholds, but it still fires callbacks on the main thread. Fine-grained scroll progress (like a 0-100% progress bar) requires either scroll events or rAF polling alongside IO.
Performance Comparison
Technique Thread Jank Risk Granularity
───────────────────────────────────────────────────────────────
scroll event + JS main HIGH continuous
requestAnimationFrame + JS main MEDIUM per-frame
Intersection Observer + JS main LOW threshold
CSS scroll-driven animation compositor NONE continuous
Only CSS scroll-driven animations achieve continuous,
jank-free progress tracking on the compositor thread.
What to Animate for Best Performance
/* FAST -- runs on compositor, no layout or paint */
@keyframes compositor-friendly {
from { transform: translateY(50px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* SLOW -- triggers layout recalculation */
@keyframes layout-triggering {
from { margin-top: 50px; height: 0; }
to { margin-top: 0; height: 200px; }
}
/* Rule of thumb: only animate transform and opacity
for scroll-driven animations. If you must animate
other properties, test on low-end devices. */
10. Browser Support and Progressive Enhancement
Current Support (2026)
Browser animation-timeline scroll() view()
─────────────────────────────────────────────────────────
Chrome 115+ YES YES YES
Edge 115+ YES YES YES
Opera 101+ YES YES YES
Firefox 110+ partial (flag) YES(flag) YES(flag)
Safari 18+ YES YES YES
Progressive Enhancement with @supports
/* Base experience: elements are visible by default */
.card {
opacity: 1;
transform: none;
}
/* Enhanced experience: scroll-driven reveal */
@supports (animation-timeline: view()) {
.card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes card-reveal {
from {
opacity: 0;
transform: translateY(60px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
JavaScript Fallback Strategy
/* Check support in JS, then add a class */
<script>
if (CSS.supports('animation-timeline', 'view()')) {
document.documentElement.classList.add('scroll-animations');
} else {
// Fallback: use Intersection Observer
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.card').forEach(el => {
observer.observe(el);
});
}
</script>
<style>
/* CSS-only experience for supported browsers */
.scroll-animations .card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
/* JS fallback for unsupported browsers */
.card:not(.visible) {
opacity: 0;
transform: translateY(60px);
}
.card.visible {
transition: opacity 0.6s, transform 0.6s;
opacity: 1;
transform: none;
}
</style>
Accessibility note: Always respect prefers-reduced-motion. Wrap your scroll-driven animations in a media query so users who are sensitive to motion see static content.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
11. Complete Real-World Examples
Example 1: Hero Parallax with Overlaid Text
<!-- HTML -->
<section class="hero">
<div class="hero-bg"></div>
<div class="hero-content">
<h1>Welcome</h1>
<p>Scroll to explore</p>
</div>
</section>
<style>
.hero {
position: relative;
height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
}
.hero-bg {
position: absolute;
inset: -30% 0;
background: url('/img/hero.jpg') center/cover;
animation: hero-parallax linear both;
animation-timeline: scroll(root);
}
.hero-content {
position: relative;
z-index: 1;
text-align: center;
animation: hero-text-fade linear both;
animation-timeline: scroll(root);
animation-range: 0% 30%;
}
@keyframes hero-parallax {
from { transform: translateY(0); }
to { transform: translateY(40%); }
}
@keyframes hero-text-fade {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.9); }
}
</style>
Example 2: Sticky Header That Shrinks on Scroll
<style>
.site-header {
position: sticky;
top: 0;
z-index: 100;
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0px 200px;
}
@keyframes shrink-header {
from {
padding-block: 1.5rem;
background: transparent;
}
to {
padding-block: 0.5rem;
background: rgba(15, 17, 23, 0.95);
backdrop-filter: blur(10px);
box-shadow: 0 2px 20px rgba(0,0,0,0.3);
}
}
.site-header .logo {
animation: shrink-logo linear both;
animation-timeline: scroll(root);
animation-range: 0px 200px;
}
@keyframes shrink-logo {
from { font-size: 2rem; }
to { font-size: 1.25rem; }
}
</style>
Example 3: Card Reveal Gallery
<style>
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
padding: 4rem 2rem;
}
@supports (animation-timeline: view()) {
.gallery .card {
animation: card-pop linear both;
animation-timeline: view();
animation-range: entry 10% entry 90%;
}
.gallery .card:nth-child(even) {
animation-name: card-pop-alt;
}
@keyframes card-pop {
from {
opacity: 0;
transform: translateY(80px) rotate(-2deg);
}
to {
opacity: 1;
transform: translateY(0) rotate(0deg);
}
}
@keyframes card-pop-alt {
from {
opacity: 0;
transform: translateY(80px) rotate(2deg);
}
to {
opacity: 1;
transform: translateY(0) rotate(0deg);
}
}
}
</style>
Example 4: Number Counter on Scroll
<style>
/* CSS counter driven by scroll -- uses @property for animatable integer */
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.counter {
animation: count-up linear both;
animation-timeline: view();
animation-range: entry 20% contain 50%;
counter-reset: num var(--num);
font-variant-numeric: tabular-nums;
}
.counter::after {
content: counter(num);
}
@keyframes count-up {
from { --num: 0; }
to { --num: 1000; }
}
</style>
Generate CSS Animations Instantly
Build keyframe animations with our visual CSS Animation Generator -- adjust easing, duration, and properties with live preview.
Try Animation Generator12. Frequently Asked Questions
What is the difference between scroll() and view() in CSS scroll-driven animations?
scroll() creates a scroll progress timeline that tracks how far a scroll container has been scrolled, from 0% at the top to 100% at the bottom. The animation progresses globally with scroll position. view() creates a view progress timeline that tracks a specific element's visibility within a scrollport. The animation progresses as that element enters, is contained within, and exits the viewport. Use scroll() for page-level effects like progress bars, and view() for element-specific effects like fade-in on scroll.
How does animation-range work with scroll-driven animations?
animation-range defines the start and end points within a scroll or view timeline where an animation should play. For view timelines, named ranges are available: entry (element entering viewport), exit (element leaving), contain (element fully visible), and cover (entire visibility duration). For example, animation-range: entry 0% entry 100% runs the animation only during the entry phase. You can mix ranges like animation-range: entry 25% cover 50% for precise control.
Do CSS scroll-driven animations work in all browsers?
As of 2026, scroll-driven animations are fully supported in Chromium-based browsers (Chrome, Edge, Opera) since version 115. Safari added support in version 18. Firefox has partial support behind a flag. For production, always use @supports (animation-timeline: view()) to provide progressive enhancement. Fallback options include Intersection Observer with JavaScript or the scroll-timeline polyfill for broader coverage.
Are CSS scroll-driven animations better for performance than JavaScript?
Yes. CSS scroll-driven animations run on the compositor thread when animating transform and opacity. This means they never block the main thread, never cause layout recalculations, and maintain smooth 60fps even during heavy JavaScript execution. JavaScript scroll listeners and requestAnimationFrame callbacks run on the main thread and compete with other work for CPU time. Even Intersection Observer, while efficient for threshold detection, still fires callbacks on the main thread.
Can I combine scroll-driven and time-based animations on the same element?
Yes. CSS supports comma-separated animation values, and each animation in the list can reference a different timeline. Set animation-timeline: view(), auto to make the first animation scroll-driven and the second time-based. For example, an element can fade in via scroll using view() and simultaneously pulse with a 2s infinite time-based animation. Each value in the comma list pairs by index with the corresponding animation-timeline value.
Conclusion
CSS scroll-driven animations represent a fundamental shift in how we build scroll-linked effects on the web. For the first time, developers can create reading progress bars, parallax effects, element reveal animations, and complex choreographed sequences using nothing but CSS -- and these animations run on the compositor thread with zero jank.
The key concepts to remember:
- scroll() tracks container scroll position -- use it for global effects like progress bars and parallax.
- view() tracks element visibility -- use it for fade-ins, reveals, and element-specific animations.
- animation-range gives you precise control over when animations start and stop within the timeline.
- Named timelines let you share a single scroll or view reference across multiple elements.
- Progressive enhancement with
@supportsensures graceful fallback in older browsers. - Performance is unmatched -- compositor-thread execution eliminates all jank from scroll-driven motion.
Start with simple effects like a progress bar or a card fade-in, then build toward more complex choreography with named timelines and staggered animation ranges. Explore our CSS Animation Generator and CSS Animation Playground to prototype your keyframe animations before wiring them to scroll timelines.