Understanding the CSS Scroll-Timeline API
The CSS Scroll-Driven Animations Level 1 specification shipped in Chrome 115 (July 2023) and Safari 17.4 (March 2024). It exposes two timeline functions — scroll() and view() — that map scroll position directly to animation progress, delegating interpolation off the main thread onto the compositor, which processes frames independently of JavaScript execution. For engineers transitioning from window.addEventListener('scroll') listeners, this means scroll-coupled motion without per-frame JavaScript cost.
Pages in this section
- CSS Scroll-Driven Animations vs IntersectionObserver — when to reach for the timeline API and when the observer is the better tool
- How to Polyfill scroll-timeline for Safari —
requestAnimationFrame+ CSS custom property fallback for browsers without native support
How scroll() and view() work
scroll() tracks how far a scroll container has scrolled — progress runs from 0 (scroll start) to 1 (scroll end), calculated as scrollTop / (scrollHeight - clientHeight).
view() tracks how much of a specific element is visible within a scroll container. Progress starts when the element begins to enter the viewport and ends when it has fully exited.
Both functions accept an optional scroll-container argument and an optional axis:
/* root viewport, vertical axis (both are defaults) */
animation-timeline: scroll(root block);
/* nearest scrollable ancestor, horizontal axis */
animation-timeline: scroll(nearest inline);
/* element visibility on the vertical axis */
animation-timeline: view(block);
Syntax reference
/* scroll() — anonymous scroll timeline */
animation-timeline: scroll( <scroller>? <axis>? );
/*
<scroller> : nearest | root | self
<axis> : block | inline | x | y
Defaults : nearest, block
*/
/* view() — anonymous view timeline */
animation-timeline: view( <axis>? <inset>? );
/*
<axis> : block | inline | x | y (default: block)
<inset> : <length-percentage>{1,2} (default: auto)
*/
/* Named scroll timeline */
scroll-timeline-name: --my-timeline;
scroll-timeline-axis: block; /* block | inline | x | y */
/* Named view timeline */
view-timeline-name: --my-view;
view-timeline-axis: block;
/* Shorthand */
scroll-timeline: --my-timeline block;
view-timeline: --my-view block;
/* Range */
animation-range: <range-start> <range-end>;
/*
Keywords: normal | entry | exit | cover | contain
Values : entry 0%, cover 50%, exit 100%, etc.
*/
Minimal working example
The following snippet runs in any browser that supports the API with zero dependencies. A sticky header fades out as the user scrolls through the first 200 px of the page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Scroll-driven fade header</title>
<style>
@keyframes fade-header {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-100%); }
}
.header {
position: sticky;
top: 0;
/* 1. Attach the animation */
animation: fade-header linear;
/* 2. Drive it with the root scroll position */
animation-timeline: scroll(root block);
/* 3. Activate only over the first 200 px of scroll */
animation-range: 0px 200px;
/* 4. Promote to compositor layer */
will-change: opacity, transform;
}
body { height: 300vh; margin: 0; }
</style>
</head>
<body>
<header class="header">Sticky header — fades out as you scroll</header>
</body>
</html>
animation-range narrows the active scroll window to the first 200 px; without it the animation would span the entire document. will-change: opacity, transform promotes the element to its own compositor layer so the browser can interpolate both properties without touching the main thread.
animation-range and timeline scoping
animation-range accepts any combination of range-start and range-end values. The named keywords map to specific moments in an element’s journey:
| Keyword | Meaning |
|---|---|
entry 0% |
element’s leading edge crosses the scroll-port’s end edge (begins entering) |
entry 100% |
element’s trailing edge crosses the scroll-port’s end edge (fully entered) |
cover 0% |
element begins to cover any part of the scroll-port |
cover 50% |
element is half-way through covering the scroll-port |
contain 0% |
element is fully contained within the scroll-port (start) |
contain 100% |
element is still fully contained within the scroll-port (end) |
exit 0% |
element’s leading edge crosses the scroll-port’s start edge (begins exiting) |
exit 100% |
element’s trailing edge clears the scroll-port’s start edge (fully exited) |
A reveal-on-scroll pattern that only activates while an element enters the viewport:
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: reveal linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 100%;
}
Named timelines for scoped containers
When you need to bind a timeline to a specific scroll container rather than the nearest ancestor or the root:
/* 1. Declare the timeline on the scroll container */
.scroll-container {
scroll-timeline-name: --product-list;
scroll-timeline-axis: block;
overflow-y: auto;
height: 400px;
}
/* 2. Reference it on any descendant */
.animated-child {
animation: slide-in linear;
animation-timeline: --product-list;
animation-range: 10% 60%;
}
The animated element does not need to be a direct child of the scroll container — --product-list is resolved by walking up the DOM tree to find the nearest ancestor that declares that scroll-timeline-name.
Compositor-safe properties
Only properties that the browser can animate on the compositor thread avoid main-thread recalculation during every scroll tick.
| Property | Compositor? | Notes |
|---|---|---|
opacity |
Yes | Promotes element to own layer automatically |
transform |
Yes | Includes translate, rotate, scale, matrix |
filter (blur, brightness, etc.) |
Partial | filter: blur() triggers raster on some engines; benchmark case-by-case |
clip-path |
No | Forces paint on every frame |
background-color |
No | Triggers paint; use opacity on a pseudo-element instead |
width / height |
No | Forces layout + paint |
font-size |
No | Forces layout |
border-radius |
No | Triggers repaint on WebKit |
will-change: transform or will-change: opacity signals the browser to pre-promote the layer. Do not apply will-change to every animated element — it consumes GPU memory. Reserve it for elements that animate continuously or at high frequency.
For a full explanation of why certain properties are safe and how the rendering pipeline routes work through style, layout, paint, and composite phases, see the rendering pipeline deep-dive.
Common implementation patterns
1. Reading progress bar
A full-width bar that grows as the user reads — the canonical use case for scroll(root block):
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress {
position: fixed;
top: 0; left: 0;
width: 100%;
height: 4px;
background: oklch(55% 0.2 300); /* mauve accent */
transform-origin: left center;
animation: grow-bar linear;
animation-timeline: scroll(root block);
will-change: transform;
}
See the complete implementation in Creating a Reading Progress Bar with scroll-timeline.
2. Scroll-reveal card
Each card fades and slides in as it enters the viewport — uses view() so each card triggers independently:
@keyframes card-reveal {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: card-reveal linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 60%;
}
3. Sticky header with direction-aware transition
A header that hides when scrolling down and reappears when scrolling up, driven purely by scroll position. For a complete walkthrough see Animating Sticky Headers on Scroll Direction Change:
@keyframes hide-header {
from { transform: translateY(0); }
to { transform: translateY(-100%); }
}
.site-header {
position: sticky;
top: 0;
animation: hide-header linear;
animation-timeline: scroll(root block);
animation-range: 80px 160px;
will-change: transform;
}
4. Horizontal gallery driven by vertical scroll
Map a horizontal translate to a vertical scroll position for a sideways reveal:
@keyframes slide-gallery {
from { transform: translateX(0); }
to { transform: translateX(calc(-100% + 100vw)); }
}
.gallery-track {
display: flex;
animation: slide-gallery linear;
animation-timeline: scroll(root block);
/* Active while the gallery's container covers the viewport */
animation-range: cover 0% cover 100%;
will-change: transform;
}
Browser support and @supports guard
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
animation-timeline: scroll() |
115 (Jul 2023) | 17.4 (Mar 2024) | 110 (flag only) | 115 (Jul 2023) |
animation-timeline: view() |
115 (Jul 2023) | 17.4 (Mar 2024) | 110 (flag only) | 115 (Jul 2023) |
animation-range keywords |
115 (Jul 2023) | 17.4 (Mar 2024) | 110 (flag only) | 115 (Jul 2023) |
Named scroll-timeline-name |
115 (Jul 2023) | 17.4 (Mar 2024) | 110 (flag only) | 115 (Jul 2023) |
Firefox ships the full API behind layout.css.scroll-driven-animations.enabled as of Firefox 110 and has not yet enabled it by default.
The safest guard pattern wraps all scroll-driven rules in a single @supports block:
/* Static fallback — applies everywhere */
.card {
opacity: 1;
transform: none;
}
/* Scroll-driven enhancement — applies only when the API is available */
@supports (animation-timeline: scroll()) {
.card {
animation: card-reveal linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 60%;
}
}
For environments where Safari < 17.4 represents a significant share of your audience, the requestAnimationFrame + CSS custom property fallback is covered in detail in how to polyfill scroll-timeline for Safari.
For the full cross-pillar strategy on @supports guards and polyfill decision trees, see Browser Support and Progressive Enhancement.
Gotchas and failure modes
-
animation-fill-modeis required to hold the end state. Withoutanimation-fill-mode: both, the element snaps back to itsfromstate when the scroll range ends. Addbothorforwardsto every scroll-driven animation that should remain at its terminal position. -
overflow: hiddenon an ancestor breaks view timelines. Aview()timeline observes scroll position in the nearest scroll container. If an ancestor hasoverflow: hidden, it creates a new stacking context but does not create a scroll container — the timeline may resolve against an unintended container. Audit ancestoroverflowvalues when aview()timeline appears not to activate. -
Named timelines do not cross shadow DOM boundaries.
scroll-timeline-namereferences are resolved within the same document tree. A Web Component’s shadow root cannot directly consume a named timeline declared in the light DOM. Use the anonymousscroll(root)form or pass coordinates through CSS custom properties as a workaround. -
will-changeon scroll containers interferes with sticky positioning. Applyingwill-change: transformto a scroll container promotes it to a new stacking context, which breaksposition: stickychildren because sticky elements position relative to the nearest scroll container. Keepwill-changeon animated children, not the container. -
Conflicting inline styles override timeline interpolation. React components that set
style={{ opacity: 0 }}or similar will override the animation even when the timeline is attached. Remove inline style overrides or drive the property exclusively through the timeline. -
animation-rangewith pixel units is relative to the scroll container, not the viewport.animation-range: 0px 200pxmeans the first 200 px of total scroll distance, not the first 200 px of the visible viewport. When you need viewport-relative activation, useentryandcoverpercentage keywords withview()instead.
Performance checklist
- Use
opacityandtransformexclusively for compositor-bound scroll animation; avoidbackground-color,width,height, orclip-pathin@keyframesattached to a scroll timeline. - Apply
will-change: opacity, transformonly to elements that animate continuously — not to every card on the page. - Scope
animation-rangetightly usingentryandexitkeywords so animations are only active while the element is on-screen; a wildly broad range keeps the compositor layer live when it does not need to be. - Prefer named timelines (
scroll-timeline-name) over root-scroll fallbacks for components inside modal overlays or fixed panels — it prevents the wrong container resolving as the timeline source. - Benchmark with the Performance panel in DevTools: record a scroll session and confirm there are zero
Layoutevents triggered during scroll, and that your animated elements appear in the Layers panel as their own compositor layers. - For
view()timelines on large lists, considercontent-visibility: autoon off-screen items — it pauses rendering work without breaking the timeline, since the element’s scroll position is still tracked. - Avoid attaching more than one independent scroll timeline to the same element via multiple
animation-*declarations; instead, useanimationshorthand with a comma-separated list to keep the compositor layer count predictable.
Related
- The Rendering Pipeline for Scroll Animations — how style, layout, paint, and composite phases interact with scroll-driven animation
- Building Scroll Progress Indicators — practical patterns for reading progress bars and section trackers
- Parallax Effects with Pure CSS — depth effects driven entirely by
scroll()timelines - Fallback Strategies for Legacy Browsers — comprehensive guide to graceful degradation across browser generations