A bar that fills as you scroll, or a section that fades in as it appears: until recently both needed JavaScript recalculating on every scroll event. CSS can do it on its own now. Instead of a clock, the animation gets a timeline tied to the scroll position. This guide shows the two timelines, how to trim the range, what it looks like in Tailwind and how to handle the fact that Firefox is still missing.
- animation-timeline swaps the clock of a CSS animation for the scroll position. The keyframes stay, only the progress comes from somewhere else.
- scroll() measures a scroll container, view() the path of an element through the viewport. For fading in, view() is almost always the one you mean.
- animation-range cuts the stretch out of that path on which the animation runs. Without it, the animation spans the whole distance.
- Firefox is still missing. With @supports the finished state stands everywhere, and the motion happens where it works.
Why a scroll listener is the wrong tool
The classic route is a listener: you attach to the scroll event, read the position on every call and compute a value from it. That works, but it happens in the most sensitive place the browser has, the main thread. Everything else is handled there too: answering clicks, computing layout, painting.
On top of that comes a trap that is easy to miss. Querying a size inside the listener, through getBoundingClientRect for instance, forces the browser to recompute layout right away. With very many calls per second that turns into stutter you cannot optimise away, because the work itself is the problem.
IntersectionObserver takes some of the load off, but it only answers a yes or no question: is the element in view? For a simple fade-in that is enough. For anything that changes smoothly with the scroll, the progress in between is missing. That gap is exactly what scroll timelines in CSS close.
An animation tied to scrolling does not need a value per frame. It needs a timeline.
Two timelines instead of a clock
A CSS animation normally runs on a clock: it lasts 400 milliseconds, and the progress follows from the time elapsed. The animation-timeline property swaps that clock for something else. The keyframes stay untouched, only the question of progress gets a different answer.
scroll() provides the progress of a scroll container. Zero percent is the very top, one hundred percent the very bottom. That is the timeline for anything reflecting the state of the whole page, such as a reading bar or a marker at the edge.
view() provides the progress of a single element through the viewport. Zero percent is the moment it enters at the bottom, one hundred percent the moment it leaves at the top. That is the timeline for anything tied to a section: fading in, shifting, scaling up.
- initial
- opacity 0, y 12
- animate
- opacity 1, y 0
- duration
- 0.5 s
- ease
- cubic-bezier(.22, 1, .36, 1)
- stagger
- 80 ms
Twelve pixels of travel and half a second. A hero needs no more.
The same keyframes, two questions: the state of the page or the path of an element through the viewport.
@keyframes aufziehen { from { transform: scaleX(0); } to { transform: scaleX(1); }} .leiste { animation: aufziehen linear both; animation-timeline: scroll();} .karte { animation: aufziehen linear both; animation-timeline: view();}
scroll() asks the container being scrolled. view() asks the element travelling through the viewport.
The reading bar in eight lines
The reading bar is the smallest example worth building: a thin bar at the top edge that fills as you scroll. The key is not to animate the width but transform with scaleX. Width means layout, scaleX means paint only, and the browser can keep that work off the main thread.
The transform-origin: left setting keeps the bar anchored on the left so it grows to the right, instead of running out from the centre in both directions. Without it the bar looks like a slider, not like progress.
Two words in the shorthand matter here. linear keeps the progress firmly tied to the scroll position, because an easing curve stretched across a whole page feels as if the bar were lagging behind your own gesture. both makes the bar hold its state before and after the range instead of snapping back.
.fortschritt { position: fixed; inset-block-start: 0; block-size: 3px; background: var(--akzent); transform-origin: left; animation: aufziehen linear both; animation-timeline: scroll(root block);}
Three curves cover everyday work.
A page with three curves feels ordered, one with twelve feels random. These three are almost always enough.
Fading in when a section arrives
For fading in, view() is the right timeline, and a second setting joins it: animation-range. Without it the animation spans the element's entire path through the viewport, from the first pixel at the bottom to the last one at the top. That is almost never what you mean, because then the section only finishes fading in as it disappears again.
The stretches of that path have names. entry is the arrival from the bottom, exit the departure at the top, cover the whole path from first to last contact, contain the stretch on which the element is fully in view. With percentages you cut a piece out of it.
So animation-range: entry 0% entry 60% means: start as soon as the top edge comes in, be finished once the element has entered sixty percent of the way. That gives a fade-in completed before anyone starts reading the section. Which is exactly what you want, because text still moving while you read it is annoying.
@keyframes einblenden { from { opacity: 0; transform: translateY(1.5rem); } to { opacity: 1; transform: translateY(0); }} .abschnitt { animation: einblenden linear both; animation-timeline: view(); animation-range: entry 0% entry 60%;}
Writing it in Tailwind
Tailwind v4 ships no dedicated classes for scroll timelines. That is no obstacle, because both pieces you need are there: custom animations through the theme and arbitrary properties in square brackets.
Keyframes and name belong in the CSS file. In v4 a variable under --animate- turns them into a utility: --animate-einblenden becomes the class animate-einblenden, and the shorthand with linear and both sits right inside the value. The animation is ready to use without any of it showing up in the markup.
The timeline joins as an arbitrary property. Spaces are written as underscores inside square brackets, otherwise the class breaks mid value. So animation-range: entry 0% entry 60% becomes [animation-range:entry_0%_entry_60%].
/* app.css */@theme { --animate-einblenden: einblenden linear both;} @keyframes einblenden { from { opacity: 0; transform: translateY(1.5rem); } to { opacity: 1; transform: translateY(0); }} /* im Markup */ <section className="animate-einblenden [animation-timeline:view()] [animation-range:entry_0%_entry_60%]">
Where it reliably goes wrong
The mechanics are manageable, but there are a few places where you reliably get stuck. Usually it is not the animation itself, it is which container the browser currently means.
- Writing scroll() with no argument while some ancestor carries overflow: hidden or auto. The browser takes the nearest scroll container, and that is not the page.
- Declaring a named timeline on an element that is not an ancestor of the animated one. The name is simply unknown there.
- Animating width, height or top. Every frame triggers layout, and the advantage over the listener is gone again.
- Leaving the default ease curve in place. Stretched across a whole page it feels like a motion that keeps catching.
- Writing scroll(root block) when the page really is what you mean. One word more, but no doubt left.
- Declaring the name on the common ancestor, or lifting it with timeline-scope far enough that both sides can see it.
- Animating transform and opacity. Both run without layout and stay calm even during fast scrolling.
- Setting linear and shaping the curve, if at all, through additional keyframes.
Browser support, fallback and calm motion
One point deserves to be said plainly: this technique has not arrived everywhere yet. Chrome and Edge have had it since mid 2023, Safari since version 26 in autumn 2025. Firefox has not enabled its implementation by default as of this article.
That is no reason to avoid it, it is a reason to build it as a bonus. And it is at the same time the answer to when switching away from framer-motion is worth it at all: for everything whose absence nobody notices.
- Progress and reading bars. Pure CSS territory. In Firefox the bar simply sits at zero, and nobody misses something they never saw.
- Sections fading in as they appear. CSS as well, but built with @supports so the text is visible right away without a timeline instead of permanently transparent.
- Motion reacting to state. A menu opening, a card waiting on data: that hangs on React, not on scrolling. framer-motion stays the right tool for it.
- Interlocking sequences across several elements. As soon as several parts have to start in a precise order, an orchestration in JavaScript is easier to read than a collection of named timelines.
For the first two cases the fallback always looks the same. The readable state lives in plain CSS, and only inside @supports does it become motion. Added to that is the query every animation deserves: anyone who set prefers-reduced-motion to reduce wants no motion while scrolling, and this kind is particularly unpleasant because it hangs directly on their own gesture.
@media (prefers-reduced-motion: no-preference) { @supports (animation-timeline: view()) { .abschnitt { opacity: 0; animation: einblenden linear both; animation-timeline: view(); animation-range: entry 0% entry 60%; } }}
The motion is the bonus, not the condition. The page has to be readable without it too.




