The basics of CSS scroll timelines
Scroll-driven animations have been in spec proposals for a decade, and after five years of development, they're finally showing up in production sites. Examples range from scrolly-telling to maze games, cover flow effects, and 3D rotations tied to scroll position. What's genuinely new is that these animations require no JavaScript, no dependencies, no libraries — just CSS. They also run off the main thread, delivering smooth, GPU-accelerated performance.
Chrome has supported scroll-driven animations since December 2024. Firefox supports them behind a flag. Safari hasn't shipped support yet, although a polyfill exists — just know that adding it means bringing JavaScript back in and losing the performance advantage.
Two types of scroll-driven timelines exist: scroll progress timelines, which link animation progress directly to a scroll container's position, and view progress timelines, which track an element's visibility within a scrollport. Both share some properties but behave differently.
Scroll progress timelines
A scroll progress timeline — often shortened to scroll-timeline animation — ties animation progress to a scroll container's position along a specific axis. Scroll forward and the animation advances; scroll backward and it reverses. There are two ways to set these up: anonymous timelines and named timelines.
Anonymous scroll timelines
The classic starting example is a reading progress bar fixed at the top of a page. The markup is a simple <div> with an ID like "progress," styled with a background color, dimensions, and a fixed position. An animation scales it from 0 to 1 along the x-axis.
#progress {
/* ... */
animation: progressBar 1s linear;
}
@keyframes progressBar {
from { transform: scaleX(0); }
}
The animation itself is ordinary CSS. The magic comes from one additional declaration on animation-timeline:
animation-timeline: scroll();
No duration needs to be specified — the scroll behavior dictates timeline length. The animation direction maps directly to scroll direction: scroll down and the bar grows, scroll up and it shrinks back.
scroll() function parameters
The scroll() function inside animation-timeline accepts two optional parameters: <scroller> and <axis>.
<scroller>— which scroll container to use:nearest(default),root, orself.<axis>— which scroll axis to track:block(default),inline,x, ory.
The reading progress example relied on defaults — the nearest scroll container (the root) and the block axis, which corresponds to vertical scrolling in left-to-right writing modes. The same effect could be written more explicitly (
animation-timeline: scroll(nearest block);
or animation-timeline: scroll(root block);
). For wide pages with horizontal overflow, the inline or x values handle that axis.
The self and inline values become useful in specific layouts, and tools like Bramus's parameter playground help with experimenting across all combinations.
Controlling progress with animation-range
The animation-range property defines which portion of the scrollable content drives the animation's start and end. Its default value is normal, which expands to a shorthand covering the container's full scrollable extent:
animation-range-start: normal;
animation-range-end: normal;
In practical percentage terms for a scroll timeline, this means the animation runs from the start of the scrollport's scrollable range to its end:
animation-range: normal normal;
Any CSS length or calc() expression works. Say a page footer is 500px tall and packed with banners and related links — excluding that from the reading progress bar means specifying an end point 500px before the bottom:
animation-range: 0% calc(100% - 500px);
See the Pen [Scroll Progress Timeline example - animation-timeline, animation-range [forked]](https://codepen.io/smashingmag/pen/azoZQym) by Mariana Beldi.
Named scroll timelines
Anonymous timelines implicitly bind to a nearby scroll container. Named timelines let you explicitly assign a scroll container that drives an animation, even when elements are positioned outside the default nearest-ancestor relationship.
The scroll-timeline-name property gives a scroll container a dashed-ident name. Then scroll-timeline-axis defines which axis triggers the animation. If omitted, the axis defaults to block. The shorthand scroll-timeline combines both name and axis:
.my-class {
/* Shorthand for scroll-container with axis */
scroll-timeline: --my-custom-name inline;
}
A horizontal gallery demonstrates why naming matters. Two animations need to run from the same scroll container: a progress bar that grows as you scroll sideways, and a background color that shifts across the scroll. The bar is absolutely positioned, which removes it from normal flow and breaks the automatic nearest-ancestor lookup, so the container gets a named timeline:
.gallery-scroll-container {
/* ... */
animation: bg steps(1);
scroll-timeline: --scroller inline;
}
The gallery itself uses that named timeline for its background color animation (
.gallery-scroll-container {
/* ... */
animation: bg steps(1);
scroll-timeline: --scroller inline;
animation-timeline: --scroller;
}
), and the absolutely positioned progress element references the same name for its own animation.
Naming makes the shared source of truth explicit, keeping both sync. If the scroller's subtree doesn't contain the animated element, a scoped name needs extra help.
The timeline-scope property
When the animated element lives outside the scroll container's subtree — say under a sibling or a higher ancestor — the timeline-scope property extends a timeline's visibility beyond the element that declares it.
The pattern: give the scroll container a name, then declare timeline-scope on a shared ancestor so the name becomes reachable by the animated element's context:
.main-container {
/* ... */
timeline-scope: --containerText;
}
With that declaration, a sibling's scroll position controls an image animation elsewhere in the layout. The axis value was omitted, defaulting to block, which is the intended vertical scroll direction.
These two properties — scroll-timeline for defining scrollers and timeline-scope for lifting names out of their local subtree — provide the full vocabulary for scroll progress timelines.
View Progress Timelines Explained
Scroll progress animations track an element as the page scrolls. The second flavor, view progress animations, work differently: they track an element as it enters or exits the scrollport — the visible area of the scrollable content. This mirrors how JavaScript's IntersectionObserver watches elements, but it's achievable purely in CSS via the view() function. As with scroll timelines, there are anonymous and named variants.
Anonymous View Timelines
Consider an image that should fade in as it scrolls into view. Using classic CSS with keyframes, you'd write something like:
img {
/* ... */
animation: fadeIn 1s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
But without scroll-driven animation, that effect runs on a timer, regardless of whether the image is actually visible. The view() function ties the animation to the element's visibility with a single declaration:
img {
/* ... */
animation: fadeIn;
animation-timeline: view();
}
Notice that there's no need for an animation-duration anymore — the animation is no longer time-based but space-based, triggering precisely as the image becomes visible.
View Timeline Parameters
The view-timeline property accepts configuration parameters, just like scroll-timeline:
animation-timeline: view( );
<inset>: Controls when the animation begins and ends relative to the element's visibility within the scrollport. It defines a margin between the scrollport's edges and the tracked element. The default isauto, but length percentages and explicit start/end values are also allowed.<axis>: Identical in purpose to the scroll-timeline axis parameter — it selects which axis the animation follows. Theblockdefault tracks vertical movement;inlinetracks horizontal; simplexoryvalues also work.
An example using both parameters:
img {
animation-timeline: view(20% block);
}
Here, the animation starts when the image is 20% visible, tied to vertical scrolling via the block axis.
Effortless Parallax Effects
The view() function also opens the door to parallax by adjusting animation properties alone. An element can translate or scale as it enters the scrollport without a single line of JavaScript:
img {
animation: parallaxMove 1s;
animation-timeline: view();
}
@keyframes parallaxMove {
to { transform: translateY(-50px); }
}
That's all it takes to build dynamic, engaging scroll interactions in a few lines of CSS.
See the Pen [Parallax effect with CSS Scroll driven animations - view() [forked]](https://codepen.io/smashingmag/pen/mybEQLK) by Mariana Beldi.
Fine-Tuning with animation-range
The animation-range property controls exactly how much of an element's visibility within the scrollport maps to the animation's progress. The default value is normal, which for view timelines translates to the element's full journey from entering to exiting the scrollport:
animation-range: normal normal;
/* Equivalent to */
animation-range: cover 0% cover 100%;
Or, written more simply:
animation-range: cover;
There are six named range values, known as timeline-range-names:
cover: The full span from the moment the element starts entering the scrollport until it fully leaves.contain: Only the period when the element is fully enclosed within the scrollport.entry: From the element's first appearance at the scrollport edge to the point it becomes fully inside.exit: From the moment the element starts sliding out until it's completely gone.entry-crossing: Tracks the element as it crosses the starting edge, from first touch to full penetration.exit-crossing: Tracks the element crossing the trailing edge, from start to complete pass.
These are meant to be mixed. For instance, you can start an animation at the beginning of an entry and end it at the conclusion of an exit:
animation-range: entry exit;
Combining named ranges with percentages enables custom midpoints, such as beginning halfway through entry and stopping halfway through exit:
animation-range: entry 50% exit 50%;
These combinations are best explored interactively. Tools like Bramus's view-timeline range visualizer are invaluable for experimentation.
Referencing Range Values in @keyframes
A powerful capability of timeline-range-names is their use inside @keyframes declarations:
See the Pen [target range inside @keyframes - view-timeline, timeline-range-name [forked]](https://codepen.io/smashingmag/pen/zxOBMaK) by Mariana Beldi.
That demo runs two logic paths: slideIn scales an element up and fades it in on entry, while slideOut reverses that on exit.
@keyframes slideIn {
from {
transform: scale(.8) translateY(100px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: scale(1) translateY(0);
opacity: 1;
}
to {
transform: scale(.8) translateY(-100px);
opacity: 0
}
}
But with range names embedded inside the keyframe rule, these can be unified into a single animation that handles both directions:
@keyframes slideInOut {
/* Animation for when the element enters the scrollport */
entry 0% {
transform: scale(.8) translateY(100px);
opacity: 0;
}
entry 100% {
transform: scale(1) translateY(0);
opacity: 1;
}
/* Animation for when the element exits the scrollport */
exit 0% {
transform: scale(1) translateY(0);
opacity: 1;
}
exit 100% {
transform: scale(.8) translateY(-100px);
opacity: 0;
}
}
entry 0%: The element's state as it first appears — scaled down and transparent.entry 100%: The state after full arrival — fully visible and scaled up.exit 0%: The moment the exit starts — still fully visible and scaled.exit 100%: The final disappearance state — scaled down and transparent.
This streamlines smooth entry and exit behavior within a single keyframe block.
Named View Timelines and timeline-scope
Named view-timeline definitions combined with timeline-scope unlock cross-element coordination — for instance, synchronizing the scroll-driven animation of an image with the animation of an entirely different paragraph elsewhere in the DOM.
While view-timeline is conceptually the view-timeline-name and view-timeline-axis shorthand (much like its scroll-timeline counterpart), its real power comes from connecting animations to an element's specific travel through the viewport. Take a demo where images in one column drive the opacity of paragraphs in another:
See the Pen [View-timeline, timeline-scope [forked]](https://codepen.io/smashingmag/pen/KwPMrBP) by Mariana Beldi.
Each image gets its own named view timeline with a distinct identifier:
.vertical-scroll-container img:nth-of-type(1) { view-timeline: --one; }
.vertical-scroll-container img:nth-of-type(2) { view-timeline: --two; }
.vertical-scroll-container img:nth-of-type(3) { view-timeline: --three; }
.vertical-scroll-container img:nth-of-type(4) { view-timeline: --four; }
This gives each image's timeline a custom name — --one, --two, and so forth. These names are then used to bind paragraphs to the image timelines:
.vertical-text p:nth-of-type(1) { animation-timeline: --one; }
.vertical-text p:nth-of-type(2) { animation-timeline: --two; }
.vertical-text p:nth-of-type(3) { animation-timeline: --three; }
.vertical-text p:nth-of-type(4) { animation-timeline: --four; }
The catch: images and paragraphs live in unrelated branches of the DOM. Since a timeline must be defined within the scope where it's referenced, a timeline-scope declaration is required on the common ancestor to bridge the gap:
.porto {
/* ... */
timeline-scope: --one, --two, --three, --four;
}
With the timeline-scope listing all timelines — --one, --two, --three, --four — both the image and paragraph components participate in the same scroll-timeline logic, despite their structural separation.
Practical Caveats and Lessons Learned
Working with the CSS Scroll-Driven Animations specification (as of December 2024) surfaces a handful of implementation details that aren’t always obvious from the spec text alone. These are the pitfalls and mental models that tend to trip up developers:
- You need a real scroll container. Scroll-driven animations only work when there is actual scrollable overflow. Resizing text or containers, or testing on large viewports, can silently eliminate the scrollable area and break the effect.
position: absolutechanges the game. Absolute positioning alters the relationship between an element and its parent, which can interfere with how scroll and view progress timelines are computed.- The browser tracks the pre-transform state. Evaluation of an element’s position happens before any
translateor similar transformations are applied. This can make view timelines trigger earlier or later than you’d expect. - Prefer
overflow: clipoveroverflow: hidden. The latter can break the scroll-seeking mechanism. The community (including Bramus and Kevin Powell) increasingly recommendsoverflow: clipas the safer default for these animations. - Mind the compositor. Stick to GPU-friendly properties like
transform,opacity, and some filters. Animatingwidth,height, orbox-shadowforces layout and paint work. That said, the spec is heading toward compositor-level support for more properties likebackground-color,clip-path,width, andheight. - Use
will-changedeliberately. Promoting elements to the GPU can help, but overuse reserves memory even when animations are idle. Apply it only where it’s genuinely needed. - Shorthand ordering matters. When using the
animationshorthand, always writeanimation-timelineafter it; otherwise, the timeline may be reset or ignored. - Progressive enhancement is still required. Combine
@media (prefers-reduced-motion: reduce)with@supportsso animations only run when the user allows motion and the browser actually supports the feature.
Here is a pattern that respects both accessibility and browser support:
@media screen and (prefers-reduce-motion: no-preference) {
@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) {
.my-class {
animation: moveCard linear both;
animation-timeline: view();
}
}
}
Spec Evolution Confusions
The specification has been in flux for more than five years, and some terminology you might find in older articles is now outdated. During development, three specific changes caused the most confusion:
- Axis naming — what used to be called “horizontal” and “vertical” axes is now simply
xandy. Firefox may still support the old naming. - Old
@scroll-timelinesyntax — this at-rule was the original way to declare scroll timelines, but the latest spec revision has replaced it. Any tutorial using it predates the current API. - Scroll-driven vs. scroll-linked — these animations were originally termed “scroll-linked.” If an article uses that older label, double-check that it reflects the current spec, especially regarding features like
timeline-scope.
Where to Go From Here
The demos referenced throughout this guide are available in a curated CodePen collection, with additional experiments added over time. For community-driven examples, there is another collection of interesting scroll-driven animation demos worth studying.
For issues or spec discussions, check the CSSWG drafts repository under the scroll-animations-1 label. Bramus maintains a comprehensive hub of demos, tools, and videos on the subject, and Google Chrome provides a thorough video tutorial series for hands-on learning.



