<!-- Canonical: https://lucioduran.com/blog/web-animations-api-scroll-driven-animations
Language: en
Content-SHA256: fa6097c381201a4dbd9924f33ea1beb6fb996ba3e73f97da83562f7a2c6a317f
-->

Frontend

# Scroll-Driven Animations with CSS scroll-timeline, ViewTimeline, and the Web Animations API

2025-12-048 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/web-animations-api-scroll-driven-animations>)

## Two Types of Scroll Timelines

CSS scroll-driven animations introduce two timeline types that replace two different JavaScript patterns:

### ScrollTimeline: Progress Through a Scroll Container

A ` ScrollTimeline ` tracks how far a scroll container has been scrolled. It replaces the classic "scroll progress bar" and "parallax effect" JavaScript patterns:

```css
/* Reading progress bar, zero JavaScript */
.progress-bar {
 position: fixed;
 top: 0;
 left: 0;
 height: 3px;
 background: #d97757;
 transform-origin: left;
 /* The animation */
 animation: grow-width linear both;
 /* Drive it by scroll position instead of time */
 animation-timeline: scroll(root);
}

@keyframes grow-width {
 from { transform: scaleX(0); }
 to { transform: scaleX(1); }
}
```

That's it. No ` window.addEventListener('scroll', ...) `, no ` requestAnimationFrame `, no throttling, no ` document.documentElement.scrollTop / document.documentElement.scrollHeight `. The browser handles everything on the compositor thread.

The ` scroll() ` function accepts two arguments:

```css
scroll(<scroller> <axis>)

/* Examples */
animation-timeline: scroll(); /* nearest scroll ancestor, block axis */
animation-timeline: scroll(root); /* root scroller (usually document) */
animation-timeline: scroll(root block); /* root scroller, vertical axis */
animation-timeline: scroll(self inline);/* element itself, horizontal axis */
animation-timeline: scroll(nearest); /* nearest scroll ancestor (default) */
```

### ViewTimeline: Element Visibility in Viewport

A ` ViewTimeline ` tracks an element's position as it crosses through a viewport (or any scroll container). This replaces Intersection Observer for animations:

```css
/* Fade-in-up as element enters viewport */
.card {
 animation: fade-in-up linear both;
 animation-timeline: view();
 animation-range: entry 10% entry 90%;
}

@keyframes fade-in-up {
 from {
 opacity: 0;
 transform: translateY(40px);
 }
 to {
 opacity: 1;
 transform: translateY(0);
 }
}
```

The ` view() ` function creates a timeline based on the element's visibility within its scroll container. The ` animation-range ` property specifies when the animation starts and ends relative to the element's journey through the viewport.

## Understanding animation-range

This is the most powerful, and most confusing, part of scroll-driven animations. The ` animation-range ` property defines which portion of the scroll timeline the animation occupies.

For ViewTimeline, there are named ranges:

```
 VIEWPORT
 ┌─────────────┐
 │ │
 cover 0% ──→ │ ┌─────────┐ │ ← entry 100% / contain 0%
 │ │ ELEMENT │ │
 cover 100%──→ │ └─────────┘ │ ← exit 0% / contain 100%
 │ │
 └─────────────┘

 entry: from element's leading edge entering to element fully inside
 exit: from element fully inside to element's trailing edge leaving
 contain: while element is fully contained within the viewport
 cover: from first pixel visible to last pixel leaving
```

Real examples:

```css
/* Animate only while entering the viewport */
animation-range: entry;
/* Equivalent to: entry 0% entry 100% */

/* Animate across the entire visibility journey */
animation-range: cover;
/* Equivalent to: cover 0% cover 100% */

/* Start animation when 25% entered, finish when 75% entered */
animation-range: entry 25% entry 75%;

/* Start when element enters, end when it starts to exit */
animation-range: entry exit;

/* Fixed pixel offsets work too */
animation-range: entry 0% entry 200px;
```

## Building a Real Parallax Section

Here is a production parallax effect. Background image moves at a different rate than the content, with a fade overlay:

```css
.parallax-section {
 position: relative;
 overflow: hidden;
 min-height: 100vh;
}

.parallax-section__background {
 position: absolute;
 inset: -20% 0; /* Extra height for parallax movement */
 background-image: url('/hero.webp');
 background-size: cover;

 /* Parallax: move 40% of its extra height as you scroll past */
 animation: parallax-shift linear both;
 animation-timeline: view();
 animation-range: cover;
}

@keyframes parallax-shift {
 from { transform: translateY(-10%); }
 to { transform: translateY(10%); }
}

.parallax-section__overlay {
 position: absolute;
 inset: 0;
 background: linear-gradient(to bottom, transparent 30%, rgba(0,0,0,0.7));

 /* Overlay fades in as section enters */
 animation: overlay-fade linear both;
 animation-timeline: view();
 animation-range: entry;
}

@keyframes overlay-fade {
 from { opacity: 0; }
 to { opacity: 1; }
}
```

Previously, this required at a minimum:

```javascript
// The old way, don't do this anymore
const section = document.querySelector('.parallax-section');
const bg = section.querySelector('.parallax-section__background');

function handleScroll() {
 const rect = section.getBoundingClientRect();
 const progress = -rect.top / (rect.height + window.innerHeight);
 const yOffset = (progress - 0.5) * 20; // -10% to 10%
 bg.style.transform = `translateY(${yOffset}%)`;
}

window.addEventListener('scroll', handleScroll, { passive: true });
// Plus cleanup, ResizeObserver for recalculation, RAF throttling...
```

## The Web Animations API: When CSS Isn't Enough

For dynamic scroll animations where the parameters aren't known at CSS authoring time, the Web Animations API provides JavaScript control with compositor-thread performance:

```javascript
// Animate element based on its own ViewTimeline
const card = document.querySelector('.product-card');

card.animate(
 [
 { opacity: 0, transform: 'translateY(60px) scale(0.95)' },
 { opacity: 1, transform: 'translateY(0) scale(1)' }
 ],
 {
 timeline: new ViewTimeline({ subject: card }),
 rangeStart: 'entry 10%',
 rangeEnd: 'entry 80%',
 fill: 'both',
 easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
 }
);
```

This is particularly useful for staggered animations where each element needs a slightly different configuration:

```javascript
// Staggered card entrance, each card has a slightly wider range
const cards = document.querySelectorAll('.grid-card');

cards.forEach((card, index) => {
 const entryStart = Math.max(0, 5 - index * 2); // vary start point
 const entryEnd = Math.min(100, 70 + index * 5); // vary end point

 card.animate(
 [
 { opacity: 0, transform: `translateY(${30 + index * 10}px)` },
 { opacity: 1, transform: 'translateY(0)' }
 ],
 {
 timeline: new ViewTimeline({ subject: card }),
 rangeStart: `entry ${entryStart}%`,
 rangeEnd: `entry ${entryEnd}%`,
 fill: 'both',
 }
 );
});
```

## Named Scroll Timelines: Cross-Element References

Sometimes you need to animate an element based on a different element's scroll timeline. Named timelines handle this:

```css
/* The scrolling container defines a named timeline */
.horizontal-gallery {
 overflow-x: auto;
 scroll-timeline-name: --gallery;
 scroll-timeline-axis: inline;
 display: flex;
 gap: 1rem;
}

/* A pagination indicator outside the gallery uses it */
.gallery-progress {
 height: 4px;
 background: #d97757;
 transform-origin: left;
 animation: scale-x linear both;
 animation-timeline: --gallery;
}

@keyframes scale-x {
 from { transform: scaleX(0); }
 to { transform: scaleX(1); }
}

/* Counter also uses the gallery's timeline */
.gallery-counter {
 animation: count-up linear both;
 animation-timeline: --gallery;
}

@keyframes count-up {
 /* You can use CSS counter-increment with steps */
 from { --progress: 0; }
 to { --progress: 100; }
}
```

## Progressive Enhancement

Not every browser supported scroll-driven animations at the same time. Here is a recommended progressive enhancement pattern:

```css
/* Base: no animation, content is always visible */
.reveal-element {
 opacity: 1;
 transform: translateY(0);
}

/* Enhanced: only apply scroll animation if supported */
@supports (animation-timeline: view()) {
 .reveal-element {
 animation: reveal linear both;
 animation-timeline: view();
 animation-range: entry 10% entry 70%;
 }

 @keyframes reveal {
 from {
 opacity: 0;
 transform: translateY(30px);
 }
 to {
 opacity: 1;
 transform: translateY(0);
 }
 }
}
```

Content is always visible and accessible. Browsers that support scroll-driven animations get the enhanced experience. No polyfill needed for the base case.

## Performance: Real Measurements

These measurements come from a Pixel 6a (mid-range Android device) using Chrome DevTrace:

```
Scroll handler (JS, throttled with rAF):
 - Main thread time per frame: 2.8ms avg, 18ms p99
 - Compositor frame drops: 3-7 per scroll gesture
 - Total JS bundle for scroll effects: ~12KB gzipped

GSAP ScrollTrigger:
 - Main thread time per frame: 4.2ms avg, 22ms p99
 - Compositor frame drops: 5-12 per scroll gesture
 - Total JS bundle: ~28KB gzipped (GSAP core + ScrollTrigger)

CSS scroll-driven animations:
 - Main thread time per frame: 0ms (runs on compositor)
 - Compositor frame drops: 0
 - JS bundle for scroll effects: 0KB
```

The difference is especially visible on pages with heavy main thread work, API calls, JSON parsing, React rendering. The CSS approach doesn't compete with that work because it runs on a completely separate thread.

## Common Patterns Worth Adopting

### Sticky header shadow

```css
.site-header {
 position: sticky;
 top: 0;
 animation: header-shadow linear both;
 animation-timeline: scroll(root);
 animation-range: 0px 100px;
}

@keyframes header-shadow {
 from { box-shadow: 0 0 0 transparent; }
 to { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
}
```

### Horizontal scroll section (scroll-jacking done right)

```css
.horizontal-section {
 overflow-x: auto;
 scroll-snap-type: x mandatory;
}

.horizontal-section__item {
 scroll-snap-align: center;
 animation: item-enter linear both;
 animation-timeline: view(inline);
 animation-range: entry;
}

@keyframes item-enter {
 from { opacity: 0.3; transform: scale(0.85); }
 to { opacity: 1; transform: scale(1); }
}
```

### Scroll-driven color theme transition

```css
:root {
 animation: theme-shift linear both;
 animation-timeline: scroll(root);
}

@keyframes theme-shift {
 0% { --bg: #1a1816; --text: #f5f0eb; }
 50% { --bg: #2a2520; --text: #f5f0eb; }
 100% { --bg: #faf9f5; --text: #1a1816; }
}

body {
 background: var(--bg);
 color: var(--text);
 transition: background 0.3s, color 0.3s;
}
```

Scroll-driven animations are one of those rare web platform additions where the new way is better in every dimension: less code, better performance, simpler mental model, no dependencies. If you're still reaching for GSAP ScrollTrigger or Intersection Observer for entrance animations, it's time to delete some JavaScript. The performance gains on mobile devices are substantial.

## Frequently asked questions

### Which browsers support scroll-driven animations?

As of early 2026, Chrome 115+, Edge 115+, and Firefox 128+ support CSS scroll-driven animations. Safari added support in Safari 18.2 (late 2025). For older browsers, the scroll-timeline polyfill from the CSS Working Group covers the basics, and you can use @supports (animation-timeline: scroll()) for progressive enhancement.

### Do scroll-driven animations affect performance?

CSS scroll-driven animations run on the compositor thread, completely off the main thread. They're as performant as CSS transitions, no JavaScript execution, no layout thrashing, no forced reflows. The Web Animations API equivalent also runs compositor-side for transform and opacity properties.

### Can I combine scroll-driven animations with regular time-based animations?

Yes. An element can have multiple animations, each with a different timeline. You can have a scroll-driven parallax effect alongside a time-based pulsing glow using the animation shorthand or individual animation-\* properties with comma-separated values.

## Tips

1. **Tip:** Always use ` animation-timeline: view() ` instead of Intersection Observer for entrance animations. It runs on the compositor thread and won't jank even if your main thread is busy parsing a 2MB JSON response. Measurements on a low-end Android phone show the IO-based approach dropped to 14fps, while the CSS approach held at 60fps.
2. **Tip:** Use ` animation-range: entry 10% entry 90% ` instead of ` entry 0% entry 100% ` for reveal animations. Starting at 0% means the animation begins when a single pixel enters the viewport, which looks like nothing is happening. The 10-90% range gives a visible, satisfying entrance.
3. **Tip:** Debug scroll timelines with ` animation-play-state: paused ` and ` animation-delay ` set to a fixed negative value. This freezes the animation at a specific point in the timeline so you can inspect intermediate states without scrolling.
4. **Tip:** For horizontal scroll-driven animations, don't forget ` scroll-timeline-axis: x ` or ` view(inline) `. The default is block axis (vertical), and when your horizontal carousel animation doesn't work, this is always the reason. This is the most common cause of horizontal scroll animation issues.

- css
- scroll-animations
- web-animations-api
- scroll-timeline
- viewtimeline
- frontend-performance
- css-animations

[Back to Blog](<https://lucioduran.com/blog>)
