Skip to content
Animation

Scroll animations without JavaScript

How animation-timeline ties a CSS animation to the scroll position, when scroll() is right and when view() is, what that looks like in Tailwind and what you plan for Firefox.

10 min readUpdated: 07 September 2026
ScrollCSSAnimationPerformance
Keyframes01
Series 04
Light that stays all day
Six lamps from one workshop in Utrecht.
See the rangeShowroom

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.

The short version
  • 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.
01

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.

02

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();}
The keyframes are identical. Only the last line decides.

scroll() asks the container being scrolled. view() asks the element travelling through the viewport.

03

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);}
A reading bar tied to the page's scroll position.
The duration no longer counts hereAs soon as animation-timeline points at a scroll timeline, a duration in seconds is ignored. The progress comes from the scroll position, not from the clock. A duration in the code does no harm, it just does nothing, and later you go looking for the wrong knob.

Three curves cover everyday work.

A page with three curves feels ordered, one with twelve feels random. These three are almost always enough.

Soft0.22, 1, 0.36, 1Anything entering
Symmetric0.4, 0, 0.2, 1Open and close, back and forth
Calm0.32, 0.72, 0, 1Long distances across the page
04

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%;}
Fading in and up, finished before the section reaches the middle.
Without both it falls back againIf both is missing from the shorthand, the end state only holds inside the range. As soon as you scroll past it, the value from the stylesheet applies again and the section turns transparent once more. One word in the shorthand saves half an hour of debugging.
05

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%]">
Once in the stylesheet, then three classes in the markup.
From the third usage on, a class of your own pays offWhen the same combination of animation, timeline and range shows up in many places, write it into your CSS as a class once. One word is then left in the markup, and if the range should be 50 percent after all, you change one line instead of twenty.
06

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.

Leads you astray
  • 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.
Holds up
  • 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.
The nearest scroll container is not always the pageEvery element with overflow: auto, scroll or hidden is a scroll container in its own right. If such a wrapper sits between your element and the page, scroll() measures it instead of the page, and the bar never moves. That is the most common reason a progress bar simply stands still.
07

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.

  1. Progress and reading bars. Pure CSS territory. In Firefox the bar simply sits at zero, and nobody misses something they never saw.
  2. 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.
  3. 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.
  4. 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%;    }  }}
It always stays visible. Motion happens only where it is wanted and possible.

The motion is the bonus, not the condition. The page has to be readable without it too.

Share
Written by
Amelie RoesmannCreative directionLeonie RoesmannDesign engineering

We build websites and web apps at Systra Studios in Münster and put the building blocks from that work here. What you read comes from real projects, not from a brochure.

Systra Studios: web design from Münster

You know the curves.
You do not have to guess the values.

A collection with previews: look at the curve, play it, copy the value. The same value for CSS and for framer-motion.

  • Every curve plays before you take it.
  • One click copies the cubic-bezier value.
  • The same values sit in every component.
Browse easings
Soft0.22, 1, 0.36, 1
Symmetric0.4, 0, 0.2, 1
Calm0.32, 0.72, 0, 1
No easing0, 0, 1, 1
FAQ

Frequently asked questions

Not for the animation itself. Timeline, range and keyframes live entirely in CSS, and the browser derives the progress from the scroll position on its own. JavaScript only comes back in if you want more than the calm end state for Firefox, that is, an actual reimplementation of the effect. In most cases that effort does not pay off.

Didn't find your question? We're happy to help.

Get in touch