Scroll-driven Animations
An animation normally reads its progress from the clock. animation-timeline points it at a scroll
container instead, so the keyframes advance as the container scrolls and rewind as it scrolls back.
The syntax is the CSS one, described at
MDN Docs.
scroll .progress-bar {
animation-name: fill;
animation-timeline: scroll();
animation-timing-function: linear;
}
@keyframes fill {
from { width: 0%; }
to { width: 100%; }
}
Which container is read
scroll() takes an optional scroller and an optional axis, in either order.
| Value | Description |
|---|---|
nearest | The default. The nearest ancestor that scrolls, which in practice is a <scroll>. |
self | The element itself, for an animation on the scroll container. |
root | The root element, when it is the thing that scrolls. |
block, y | The default axis, which is the vertical one. |
inline, x | The horizontal axis. |
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.hint { animation-name: fade; animation-timeline: scroll(nearest inline); }
scroll { animation-name: fade; animation-timeline: scroll(self); }
There are no writing modes here, so block is always the vertical axis and inline always the
horizontal one.
View timelines
view() measures the animated element itself rather than the scroll position: progress runs from
the moment the element is about to appear at one edge of the scroll view to the moment it has
disappeared past the other. That whole passage is the cover range, and it is what view() uses
unless animation-range narrows it.
.card {
animation-name: rise;
animation-timeline: view();
animation-range: entry;
}
view() takes an axis and an inset, in either order. The inset is one or two lengths, percentages
or auto, and shrinks the scroll view the element is watched in — one value applies to both edges,
two are the start and the end, and a percentage is of the scroll view along that axis. A positive
inset delays the start and brings the end forward, which is how an animation is made to finish
before the element reaches the very edge.
.card { animation-timeline: view(block 20% 10px); }
The container is always the nearest ancestor that scrolls; view() has no scroller keyword, since
the element it follows already says which scroll view it is in.
Named timelines
scroll-timeline-name gives a scroll container a name, and view-timeline-name gives an element
one for its own passage through its scroll view. animation-timeline takes that name in place of
scroll() or view(). The name is a dashed identifier, and scroll-timeline-axis /
view-timeline-axis pick the axis where the name is declared rather than at the point of use. The
scroll-timeline and view-timeline shorthands set the name and the axis together; the inset is
set on its own with view-timeline-inset.
scroll#feed {
scroll-timeline: --feed block;
}
.header {
animation-name: shrink;
animation-timeline: --feed;
}
A name is visible to the element that declares it and to its descendants. It is the way to read a container that is not an ancestor of the animated element’s nearest scroller, one of several scrollers by name, or — for a view timeline — to animate a child while the parent is the element being followed.
.card { view-timeline: --card; }
.card .badge {
animation-name: pop;
animation-timeline: --card;
}
Widening where a name is visible
Looking the name up from the animated element upwards means a sibling of the scroll container finds
nothing, which is exactly the case a progress bar next to a scroll view runs into. timeline-scope
on an ancestor of both lends the name to that ancestor’s whole subtree, and the timeline declared
anywhere under it answers to the name everywhere inside.
.reading { timeline-scope: --reader; }
.reading scroll { scroll-timeline: --reader; }
.reading .progress {
animation-name: fill;
animation-timeline: --reader;
}
It takes one name or a comma-separated list of them, or none. Only one timeline may answer to a
scoped name: if two elements under the scope declare it, or none does, the name is ambiguous and the
timeline is inactive. The scope claims the name either way, so the lookup stops there rather than
carrying on to an outer declaration.
Choosing part of the timeline
animation-range-start and animation-range-end, or the animation-range shorthand, say which
part of the timeline the animation runs over. Each end is a named range, an offset, or a named
range with an offset into it.
| Value | Description |
|---|---|
normal | The default: the start or the end of the whole timeline. |
<length>, <percentage> | An offset from the start of the range, the whole timeline if no range is named. |
cover | The element’s whole passage through the scroll view. |
contain | While the element is entirely inside the scroll view, or entirely covers it. |
entry, exit | Coming in at one edge, and leaving past the other. |
entry-crossing, exit-crossing | Crossing an edge, which differs from entry and exit only for an element taller than the scroll view. |
.card {
animation-timeline: view();
animation-range: entry 25% cover 75%;
}
A range name written on its own covers the whole of that range, so animation-range: entry is
entry to entry; anything else leaves the end at normal. The named ranges describe where the
element is, so they only mean something on a view timeline — on a scroll timeline every one of them
is the whole scroll range, while offsets still narrow it.
What the other animation properties mean
Progress stands in for the clock, so the properties that describe time no longer apply.
animation-duration, animation-delay and animation-play-state are ignored. The rest keep
working:
animation-timing-functionshapes progress just as it shapes time, so the defaulteasemakes the middle of the scroll range land past the middle of the animation. Uselinearto follow the scroll exactly.animation-iteration-countdivides the range into that many cycles. An infinite count divides a range that is finite, so it counts as one.animation-directionapplies per cycle, soalternatewith two iterations plays forwards over the first half of the range and backwards over the second.animation-fill-modehas nothing to fill: outside its range the animation holds its first or its last keyframe rather than stepping aside.
The animation shorthand takes neither a timeline nor a range, so like every other sub-property it
leaves out, it resets both. Set them after the shorthand, or in a later rule.
.bar {
animation: fill linear;
animation-timeline: scroll();
}
Inactive timelines
A timeline with no container to read is inactive, and an animation on an inactive timeline has no
effect at all, not even the fill it would otherwise leave behind. That covers
animation-timeline: none, a scroll() or view() that finds no scroll container, and a name that
no ancestor declares. The element simply shows its unanimated style, which makes an animation that
only applies inside a scroll view safe to declare everywhere.
A container with nothing to scroll yet sits at the start of its range, so its animation holds the first keyframe until there is enough content to scroll.
Notes
- An animation reads the container’s scroll offset after layout, so a container whose content resizes in the same frame is read on the next one.
- A scoped name is resolved by searching the scoping element’s subtree, so keep the scope on the nearest ancestor that covers both the timeline and the elements reading it.