Mapping keyframes to scroll position
CSS keyframe animations have long been the standard way to animate elements on the web. They are time-based: you define percent values that map to points in a duration, and the browser interpolates between them as the clock ticks.
The Animation Timeline API changes that mental model. Instead of mapping your keyframes to a time duration, you can map them to scroll progress. A keyframe animation that fades an element in could start when that element enters the viewport and end when it leaves, with the scroll position acting as the scrubber.
The API is built on top of existing CSS primitives. If you already know how to write @keyframes, you mostly know how scroll-driven animations work—you just swap the timeline. Consider a standard fade-in keyframe:
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
To make this scrub with scrolling, you set animation-timeline: view(). This tells the browser to use the element's progress through the viewport, from 0% (first pixel visible) to 100% (last pixel about to leave), as the input for the animation percentages. Scrolling back rewinds the animation; scrolling forward plays it.
Because it is still a regular keyframe animation, timing functions work as expected. You can use an easing curve or even a linear() function to create spring-like behavior:
<style>
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.box {
--super-ease-out:
cubic-bezier(0.15, 0.75, 0.35, 1);
animation: spin var(--super-ease-out);
animation-timeline: view();
}
</style>
<p>
👇 Scroll down here 👇
</p>
<div class="wrapper">
<div class="box"></div>
</div>
Spring-based easings via the linear() timing function are also supported in this context, which gives designers fine-tuned control over the feel of the motion as the user scrolls.
Controlling the range
By default, the view() timeline covers the full "cover" range: the animation starts when the element's first pixel enters the viewport and ends once its last pixel exits. The animation-range property lets you change those boundaries.
The contain keyword shifts the start point to when the element is fully inside the viewport, and the end point to when it is about to become partially out of view. This is useful for animations that need the whole element to be visible to make sense.
For entry- and exit-specific effects, the entry range starts when the element peeks into the viewport and ends when its bottom edge has completely arrived. The exit range applies as the element crosses the top of the viewport on its way out.
<style>
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
img {
animation: fadeIn linear;
animation-timeline: view();
animation-range: entry;
}
</style>
<p>
👇 Scroll down here 👇
</p>
<img
alt="A capybara sleeping peacefully on the grass"
src="/img/capybara/a.jpg"
/>
<img
alt="A capybara chewing on some vegetation, with some white birds in the background"
src="/img/capybara/b.jpg"
/>
<img
alt="A baby capybara walking on top of an adult capybara"
src="/img/capybara/c.jpg"
/>
<img
alt="A close-up shot of a capybara, with a human nearby"
src="/img/capybara/d.jpg"
/>
You can apply both entry and exit animations to the same element by using comma-separated keyframe animations. Each animation gets its own animation-timeline, and the ranges operate independently:
<style>
img {
animation:
fadeIn linear,
fadeOut linear;
animation-timeline: view(), view();
animation-range: entry, exit;
}
</style>
<p>
👇 Scroll down here 👇
</p>
<img
alt="A capybara sleeping peacefully on the grass"
src="/img/capybara/a.jpg"
/>
<img
alt="A capybara chewing on some vegetation, with some white birds in the background"
src="/img/capybara/b.jpg"
/>
<img
alt="A baby capybara walking on top of an adult capybara"
src="/img/capybara/c.jpg"
/>
<img
alt="A close-up shot of a capybara, with a human nearby"
src="/img/capybara/d.jpg"
/>
For more precise control, animation-range-start and animation-range-end accept percentage values within a named range. For instance, you can specify that the animation should run from the very start of the cover range to the point where the element is halfway through it—right in the center of the viewport.
<style>
@keyframes slideIn {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(0%);
}
}
.shape {
animation: slideIn backwards;
animation-timeline: view();
animation-range-start: cover 0%;
animation-range-end: cover 50%;
}
</style>
<p>
👇 Scroll down here 👇
</p>
<div class="wrapper">
<div class="shape">
<img alt="" src="/img/shape-circle.svg" />
</div>
<div class="shape">
<img alt="" src="/img/shape-square.svg" />
</div>
<div class="shape">
<img alt="" src="/img/shape-triangle.svg" />
</div>
<div class="shape">
<img alt="" src="/img/shape-star.svg" />
</div>
</div>
There is also a four-value shorthand for animation-range, but the long-form properties tend to be clearer when the start and end conditions are different.
Tracking overall scroll progress
The API also offers a second primitive: the scroll progress timeline. While view() tracks a single element's position in the viewport, a scroll progress timeline measures how far the whole scroll container has traveled. This is a useful pattern for the progress bars commonly seen on long-form articles, where a bar at the top of the page fills as the user reads down. For most other use cases, view-based timelines are more practical, as they directly tie animation to the object the user is looking at.
Linking one element's progress to another's animation
A more advanced feature is the ability to decouple the measured element from the animated one. This requires a scratch timeline. The idea is that you track element A's viewport progress and use it to drive the animation on element B, which might be positioned elsewhere or even pinned with position: sticky.
To create the tracking, add a view-timeline declaration to the element you want to measure, giving it a custom property name such as --tracked-elem. On the element to animate, reference that same name in animation-timeline.
A significant gotcha arises with variable scope. These timeline names are not global; they are only visible to the element that declares them and its descendants. If the target of the animation is a sibling of the tracked element, this direct approach fails. The fix is to hoist the scope with the timeline-scope property on a shared ancestor, such as a <main> wrapper. Declaring timeline-scope: --tracked-elem on that ancestor makes the variable available to both the source and the target of the timeline.
<style>
main {
/* Instantiate a new variable here: */
timeline-scope: --tracked-elem;
}
.content {
/*
Create a new view progress timeline and
assign it to the variable:
*/
view-timeline: --tracked-elem;
}
.square {
animation: fadeIn backwards, fadeOut forwards;
/* Reference the named view progress timeline: */
animation-timeline: --tracked-elem, --tracked-elem;
animation-range: entry, exit;
}
</style>
<main>
<div class="left col">
<div class="square"></div>
</div>
<div class="right col">
<p class="content">← A square appears!</p>
</div>
</main>
This system of named timelines and scoped variables adds a layer of architectural flexibility that you don't get with the simpler single-element view() syntax. It is well suited to interactive components with fixed or sticky decorative elements that respond to another part of the page's movement.



