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 - scroll() vs view(): Choosing the Right Timeline Function — decision criteria for container-progress versus element-visibility timelines
- CSS animation-timeline vs WAAPI ScrollTimeline: Which API When — declarative CSS versus the JavaScript
ScrollTimelineconstructor, and when each wins
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-topic 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.
Frequently Asked Questions
Why does scroll() pick the wrong element?
Because nearest — the default — means the nearest scrollable ancestor, and pages frequently have more scroll containers than their authors remember. A wrapper with overflow: auto set for a completely different reason, a modal body, or a component that scrolls internally will all be selected ahead of the document. The fix is either scroll(root block) when you mean the page, or a named timeline when you mean a specific container that is not the nearest one.
Diagnosing it is quick: scroll each candidate container in turn and see which one moves the animation.
Nothing happens at all — where do I start?
Check that the resolved scroller can actually scroll. A container whose content fits produces no scroll range, so progress is undefined and the animation sits at its initial state. This accounts for a large share of “the timeline does not work” reports, and it is especially common on the reduced test case built to isolate the problem, which is shorter than the real page.
After that, check the computed animation-timeline value, then the range. Those three checks in that order resolve nearly everything.
When should a range be something other than the default?
Almost always. The default range spans the subject’s entire journey through the scrollport, which means a reveal begins the instant one pixel appears and finishes as the last pixel leaves — spread thinly over far more scroll than the effect wants. Ranges like entry 25% to entry 75% concentrate the animation into the part of the journey where the reader is actually looking at the element.
Ranges are also how asymmetric effects are built: a start expressed in one named range and an end in another produces behaviour that would otherwise need two animations.
Can one element be driven by two timelines?
Not for a single animation — an animation has one timeline. An element can carry several animations, each with its own timeline, which is how an element can both fade according to its own visibility and drift according to the page’s scroll position. The constraint is the usual one: if two of those animations target the same property, one of them wins and the other is invisible.
That constraint is the reason the multi-timeline pattern normally pairs a view()-driven opacity animation with a scroll()-driven transform, rather than two animations that both want to move the element.
Does the timeline keep running when the element is off-screen?
A scroll() timeline does, because its progress depends on the scroller rather than on the subject — the value is defined whether or not anything is looking at it. A view() timeline is defined across the subject’s journey through the scrollport and clamps outside it, so an element far below the fold sits at 0% and one scrolled well past sits at 100%.
That clamping is what makes view() timelines safe to attach to every item in a long list: elements outside the scrollport are not doing per-frame work, they are simply resting at an endpoint. The cost of a hundred such timelines is close to the cost of one.
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