What it is
Pure CSS animations that run off scroll position instead of time. An element gets
animation-timeline: view(), so its animation advances while the element crosses the screen, and
animation-range picks which part of that crossing drives it (entry, cover, contain, exit,
plus a percentage). No JavaScript, no IntersectionObserver.
Use it
.item {
animation: fade-in-out linear both;
animation-timeline: view(); /* progress = the item crossing the screen */
animation-range: entry 20% cover 120%; /* which part of that crossing drives it */
}
entry (coming in), exit (going out), contain (fully on screen), cover (any part on screen), each with a
percentage. For a value tied to the whole page instead of one element, use animation-timeline: scroll().
Range Lab shows what each range does as you scroll. Works in Chrome, Edge and Safari 26+; Firefox only behind a
flag, so keep the content readable without the animation.
The screens
| Screen | What it shows |
|---|---|
| Original | The email's HTML word for word: the numbered list fades in and out (entry 20% cover 120%). |
| Range Lab | Pick the range start and end, the keyframes and the timing; the CSS is printed live, and every item shows its own progress (0-100%) through the chosen range. |
| Effects | Seven uses of the same idea, each card with its CSS: fade up, clip-path wipe, blur in and out with range names inside @keyframes, 3D tilt, a number that counts up, colour over the whole pass, stagger by range instead of delay. A scroll() progress bar on top. |
| Landing page | The original list inside a whole page: header that shrinks over the first 240px (scroll(root)), hero that sinks as it leaves (exit), a sideways card track driven by vertical scroll (named view-timeline + contain), a call to action that scales in. |
Worth knowing
- A scroll value can be printed with no JavaScript: register an integer with
@property, animate it on the same timeline, and print it withcounter-reset: p var(--p); content: counter(p). Range Lab's readouts and the Effects count-up both use this. - When the item fades, anything inside it fades too. To keep a readout sharp, name the timeline on the
item (
view-timeline: --item block) and animate the children against--iteminstead of the item itself. This behaves the same asview()on the item. - Range names can go inside
@keyframes(entry 0%,exit 100%), so one animation can do an in-and-out withoutanimation-range(Effects card 3). - A sideways track needs a tall parent with a named timeline and a sticky child;
contain 0%tocontain 100%is exactly the time the sticky child is pinned. @supports not (animation-timeline: view())shows a notice (and in Landing page, lays the track out as a normal side-scroller);prefers-reduced-motionstops every animation and shows content at rest.