From a clock to the scroll
A CSS animation normally runs on the document’s clock: it starts, plays for its duration, and ends. animation-timeline swaps that clock for a scroll position. The keyframes are the same; their progress, 0% to 100%, now comes from how far something has scrolled, forwards or backwards.
@keyframes fade-up {
from { opacity: 0; translate: 0 40px; }
to { opacity: 1; translate: 0 0; }
}
.reveal {
animation: fade-up linear both;
/* After the shorthand, which resets both of these. */
animation-timeline: view();
animation-range: entry 0% cover 50%;
}There is no duration: the scroll sets the pace, so the shorthand leaves it out. The order matters. The animation shorthand resets animation-timeline and animation-range to their defaults, so both go after it. both holds the first keyframe before the range starts and the last one after it ends, which is what keeps the card hidden until it arrives.
view() or scroll()
scroll() follows a scroller’s position: 0% at the top, 100% at the bottom, the same for every element that uses it. That suits anything tied to the whole page, like a reading progress bar.
.progress {
position: fixed;
inset: 0 0 auto;
height: 4px;
background: currentColor;
transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: scroll(root);
}
@keyframes grow {
from { scale: 0 1; }
to { scale: 1 1; }
}scroll() takes the scroller and the axis: nearest (the default, the closest scrolling ancestor), root (the page) or self, and block, inline, x or y. The scroll progress bar generator builds this bar on any edge, or as a ring, for the page or a single article.
view() follows one element’s trip through its scroller, from the first pixel in to the last pixel out. Every element gets its own timeline, which is why it is the one for reveals. Fig. 1 shows both on the same page: one long bar for scroll(root), and a short one per card for view().
When the element that moves is not the one that crosses the screen, name the timeline. view-timeline on a section makes a view() timeline that its descendants can use by name (scroll-timeline does the same for a scroller, and timeline-scope on a common ancestor lifts a name to elements outside it):
/* The section names a timeline; its heading animates on it. */
.section { view-timeline: --section; }
.section h2 {
animation: fade-up linear both;
animation-timeline: --section;
animation-range: entry 0% entry 100%;
}animation-range, name by name
A view() timeline has named stretches, and animation-range picks a start and an end on them, each a name and a percentage. For an element shorter than the viewport:
| Range | 0% is when | 100% is when | svg-scroll-draw trigger |
|---|---|---|---|
| cover | its top meets the bottom of the scroller | its bottom meets the top | 'top bottom' → 'bottom top' |
| entry | its top meets the bottom | its bottom meets the bottom: fully in | 'top bottom' → 'bottom bottom' |
| contain | its bottom meets the bottom: fully in | its top meets the top: about to leave | 'bottom bottom' → 'top top' |
| exit | its top meets the top | its bottom meets the top: gone | 'top top' → 'bottom top' |
With no animation-range at all, a view() animation runs over the whole of cover. The ends do not have to share a name: entry 0% cover 50% starts as the element appears and finishes when its centre reaches the centre of the screen, the thin bar in Fig. 1. The scroll trigger visualizer shows the same arithmetic for the library’s edge-and-line triggers in the last column, with progress running 0 to 1 as you scroll.
To fade something in as it arrives and out as it leaves, put the range names in the keyframes themselves. One animation covers both ends, so nothing has to decide which of two animations wins in the middle:
/* Keyframes can name the range they belong to. */
@keyframes in-and-out {
entry 0% { opacity: 0; }
entry 100% { opacity: 1; }
exit 0% { opacity: 1; }
exit 100% { opacity: 0; }
}
.card {
animation: in-and-out linear both;
animation-timeline: view();
}Browser support and a fallback
| Browser | Since | Notes |
|---|---|---|
| Chrome, Edge | 115 | Since July 2023: view(), scroll(), animation-range and named timelines. Opera, built on the same Chromium, has them too. |
| Safari (macOS, iOS) | 26 | Shipped with Safari 26. Earlier Safari has none of it. |
| Firefox | 159 | On by default since October 2026. Before that it sat behind a preference, off for most readers. |
A browser without scroll timelines ignores animation-timeline and runs the animation on the clock, for no time at all, so the reveal above still ends up shown. A progress bar does not: it shows full from the start. Rather than rely on that, test for support and animate only when it is there:
/* Shown as written by default; animated only where scroll timelines
exist, and only for readers who have not asked for less motion. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 50%;
}
}
}If older browsers should still get a reveal, the usual one plays on a timer when an IntersectionObserver sees the element. The script marks the page first, so the hidden state only exists where something will undo it:
// No scroll timelines: reveal each element once, on a timer, instead.
if (!CSS.supports('animation-timeline: view()')) {
document.documentElement.classList.add('io-reveal');
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('is-in');
io.unobserve(entry.target);
}
});
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
}@media (prefers-reduced-motion: no-preference) {
.io-reveal .reveal {
opacity: 0;
translate: 0 40px;
transition: opacity 0.6s ease-out, translate 0.6s ease-out;
}
.io-reveal .reveal.is-in {
opacity: 1;
translate: 0 0;
}
}The CSS scroll-driven animation generator writes the @supports version of seven effects and tests your browser live. A scroll reveal that plays on a timer, step by step, is the subject of how to animate text on scroll.
Why it is not working
Four causes cover most scroll-driven animations that do nothing.
The shorthand came last. animation after animation-timeline resets it, and the element simply appears:
.reveal {
animation-timeline: view();
/* Resets animation-timeline to auto: this now runs on the clock,
for 0s, and the element just appears. */
animation: fade-up linear both;
}An ancestor hides overflow. overflow: hidden on any axis makes an element a scroll container, even when it cannot scroll, and view() follows the nearest one. In all three engines the animation then never runs and the element sits at its own style. overflow: clip clips without making a scroller:
/* Makes .page a scroll container. view() follows it, it never
scrolls, and every reveal inside sits still. */
.page { overflow-x: hidden; }
/* Clips the same, and is not a scroll container. */
.page { overflow-x: clip; }A range name on scroll(). entry, exit, cover and contain belong to view(). A scroll() timeline has none of them, so the browser ignores them and plays over the whole scroll.
No support. The @supports block is skipped, by design. Check with CSS.supports('animation-timeline: view()') in the console.
When you still need JavaScript
A scroll timeline is a position, not an event, and that sets its limits. It cannot play once and stay: scroll back and it plays backwards. It cannot tell your code it finished, or hand its progress to a script that counts a number up. And it does nothing in a browser without support. For those, a library such as svg-scroll-draw runs the same animation on its own engine:
import { scrollAnimate } from 'svg-scroll-draw';
scrollAnimate('#card', {
props: { opacity: [0, 1], transform: ['translateY(40px)', 'translateY(0px)'] },
trigger: { start: 'top bottom', end: 'center center' }, // entry 0% → cover 50%
once: true, // keeps the furthest point reached
});With once, a custom window or a callback, scrollAnimate runs on JavaScript. Leave them out, keep to properties and easings CSS can write, and where view() exists, it writes the CSS from this post itself: @keyframes on animation-timeline: view() over cover 0% cover 100%, and the JavaScript engine everywhere else. The native CSS path post lists every condition, and the reference has the option that turns it off.
— / next
Build one and copy the CSS
Pick an effect, choose the range, scroll the preview, and copy the CSS with its @supports fallback, or the same animation as svg-scroll-draw code.
Star on GitHubFound this useful? A star helps others find it.