How to Make Scroll-Driven CSS Animations
You want a thing on the page to move as somebody scrolls, and you would rather not ship a library to do it. Good news: you no longer have to.
A scroll-driven CSS animation is an ordinary @keyframes animation with its
clock swapped out for a scroll position. Two declarations do it.
animation-timeline says which scroll position to read, and
animation-range says which slice of it your keyframes get spent over. No
listener, no library, no JavaScript at all.
This is the gentle half of the subject. It is not scrolljacking, nothing gets locked in place, and the page scrolls exactly the way the visitor expects. If you want the version where a section pins and holds somebody there, that is the next post up, and it is built out of everything below. Start here.
An animation with no duration
Here is the one idea, and everything else is detail. A normal CSS animation runs on a clock: you give it half a second and the browser plays it. A scroll-driven animation has no clock and no duration. If you set one it is ignored. What it has instead is a distance, and the visitor moves along that distance by scrolling. Forwards, backwards, fast, slow, or dragged halfway and left there. It is not a playback. It is a scrub.
That is why animation-delay and animation-duration stop being the
dials you reach for, and animation-range becomes the only one you will really
spend time on.
Two other habits come with it. Set the timing function to linear, because easing
is applied between keyframes and a scrub that speeds up and slows down under a steady finger
feels broken rather than smooth. And set animation-fill-mode: both, so the
from-state holds before the range starts and the to-state holds after it ends. Remember that
second one. It comes back later as the worst trap in the whole feature.
Timeline one: scroll(), for how far something has scrolled
scroll() gives you the progress of a scroll container. 0% when it is at the top,
100% when it is at the bottom, and it does not care where any particular element is. That
makes it the right tool for anything page-wide: a reading progress bar, a chapter rail, a
colour that shifts across a long document.
.progress_bar {
position: fixed;
inset: 0 0 auto;
height: 4px;
background: #138ECF;
transform-origin: left;
animation: progress-grow linear both;
animation-timeline: scroll(root block);
}
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
That is a working reading progress bar. It takes two arguments, both optional. The first names
the scroller: nearest is the closest scrolling ancestor and is the default,
root is the document itself, and self is the element the
declaration is on, which is how a scroller reports its own progress. The second names the
axis: block, inline, x or y.
root is doing real work in that snippet. The bar is position: fixed,
so it has no useful scrolling ancestor to find, and nearest would resolve to
nothing. Naming the document scroller explicitly is what makes a fixed element able to read a
page it is not really inside.
Timeline two: view(), for where an element is on screen
view() is the one you will use more. It measures a single element’s passage
across the viewport: 0% as it starts to appear, 100% once it has gone. Every element on the
page gets its own, independently, with no coordination between them.
.reveal {
animation: reveal-rise linear both;
animation-timeline: view();
animation-range: entry 10% entry 90%;
}
@keyframes reveal-rise {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: none; }
}
That is the fade-and-rise reveal that half the internet loads a library for, in eight lines, running off the main thread, and it goes backwards correctly when somebody scrolls back up without you writing a single line to handle that case.
view() takes an axis, the same four values, and an inset. The inset shrinks the
box the timeline is measured against, which is how you say “do not start counting until
it is properly on screen”. view(block 20%) pulls both ends of the viewport
in by a fifth, so the reveal begins later and finishes earlier than it otherwise would. One
value applies to both ends, two values set the start and the end separately, and a negative
value grows the box instead.
The ranges, and the one that means two different things
A view() timeline is carved into named ranges, and picking the right one is most
of the craft. There are four you will use.
- entry is the arrival: from the moment the element first appears at the bottom edge to the moment it has fully entered. The right range for anything that should be finished by the time somebody is looking at it.
- contain is the middle, and it is the one worth understanding properly. See below.
- exit is the departure: from the moment the element starts to leave until the last of it goes.
- cover is all three end to end, the element’s entire life on screen. Good for
parallax, and a trap when you meant
entry, because a cover-ranged animation is already well underway before the element is fully visible.
Here is the part that catches people. What contain means flips depending on whether your element is shorter or taller than the screen. If it is shorter, contain is the span during which the whole element sits inside the viewport. If it is taller, it is the span during which the element completely covers the viewport, which is exactly the pinned span of a tall track. Same word, opposite geometry, and it is the reason a range that behaved impeccably on a card does something baffling on a full-height section.
There are two more you will meet less often. entry-crossing and
exit-crossing measure edges crossing edges rather than containment. For anything
shorter than the screen they are identical to entry and exit, which
is why most people never notice them; they only diverge once the subject is taller than the
viewport.
The animation-range shorthand takes a start and an end, and the two halves do not
have to name the same range. entry 50% contain 100% is legal and useful: begin
halfway through the arrival, finish as the element starts to go. You can also set
animation-range-start and animation-range-end separately, which is
handy when a shared class sets one and a variant overrides the other, and you can use lengths
rather than percentages if you would rather think in pixels.
Named timelines, for when the animated thing is somewhere else
scroll() and view() are anonymous: the element reads its own passage,
or its own nearest scroller. Often the thing you want to animate is not in that position. The
fix is to name the timeline where it is created and refer to it by name where it is used.
.gallery {
overflow-x: auto;
scroll-timeline: --gallery inline;
}
.gallery_frame {
timeline-scope: --gallery;
}
.gallery_frame .gallery_bar {
transform-origin: left;
animation: progress-grow linear both;
animation-timeline: --gallery;
}
A progress bar for a horizontal gallery, sitting outside the gallery so it does not scroll away
with it. scroll-timeline names the scroller’s own progress.
timeline-scope on a shared ancestor makes that name visible to everything inside
it, rather than only to the scroller’s own descendants. view-timeline is the
same idea for a view timeline, and it is the form
the pinned sections on this site
use, because there the element being measured and the elements being animated are deliberately
different boxes.
Parallax, since somebody always asks
.hero_photo {
animation: photo-drift linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
@keyframes photo-drift {
from { transform: translateY(-8%); }
to { transform: translateY(8%); }
}
This is the one case where cover is obviously right: you want the drift running
the entire time the photo is on screen, including the parts where it is only half visible.
Keep the travel small. Parallax stops reading as depth and starts reading as a bug somewhere
around fifteen percent.
Three traps, and the first one is genuinely dangerous
A collapsed timeline holds the from-state for ever
This is the one that ships broken pages. Your animation runs with
animation-fill-mode: both, which holds the from-state before the range begins.
Now put that on a browser that does not support scroll timelines, or on a visitor who has
asked for reduced motion and had the whole thing switched off. The timeline cannot resolve.
The animation does not helpfully fall back to its finished state. It holds the from-state
permanently, and if your from-state is opacity: 0, your content is now
present in the HTML, read perfectly by a screen reader, and completely invisible on screen.
So build it the other way up. The un-animated page is the real page, and the animation is added on top only where it can actually run.
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: reveal-rise linear both;
animation-timeline: view();
animation-range: entry 10% entry 90%;
}
}
}
Nothing outside that block hides anything. A browser without scroll timelines, and a visitor who has asked their operating system for less movement, both get an ordinary page with all of its content visible, which is the correct outcome for both of them. This is not an edge case to tidy up later: reduced motion is a real slice of real traffic, and getting it wrong does not give those people a calmer page, it gives them a blank one.
The animation shorthand resets the timeline
animation-timeline is one of the properties the animation shorthand
resets. So this silently does nothing:
.reveal {
animation-timeline: view();
animation: reveal-rise linear both; /* resets the line above */
}
Put the shorthand first and the timeline after it, which is how every example in this post is
written. Same for animation-range. Nothing warns you, the animation simply plays
on the clock the moment the page loads and is finished before anybody scrolls.
An overflow on an ancestor kills it, silently
A timeline resolves against the nearest ancestor scroll container, and setting
overflow: hidden on an element makes it one. So if you wrap your animated thing
in a frame with hidden overflow, which is the natural way to build a frame, that frame becomes
the scroll container, it never scrolls, and your animation sits at one end for ever. I have
had a signature effect on a page that had never once run. Check the ancestors before you
debug the keyframes.
The related version, if you need to bound horizontal overflow on a page: use
overflow-x: clip, not hidden. Clip does not create a scroll
container, so timelines keep resolving and position: sticky keeps working. That
one is a five minute fix and a two hour diagnosis.
Keep it cheap
Animate transform, opacity and filter, and the browser
can run the whole thing on the compositor without touching layout. Animate
height, top, margin or anything else that moves other
elements around, and you have signed up for layout and paint work on every frame of every
scroll. It will look fine on your machine and terrible on a four year old phone.
The other saving is the one you already made by getting this far: no library. The reveal above is eight lines of CSS instead of a few dozen kilobytes of JavaScript that has to load, parse and run before anything moves, and the weight you never add is the only weight you never have to optimise.
Support, and the sentence that will age
Chromium browsers have had scroll-driven animations for a while and Safari has them. Firefox is
the holdout at the time of writing, which is exactly the sort of claim that goes stale, so
check rather than trusting a post dated August 2026. What matters more than the current state
of that list is that @supports makes it a non-problem. Build the page so the
un-animated version is the complete one, add the motion inside a feature query, and a browser
that cannot do it renders a page that is not missing anything.
If you want a richer fallback than nothing, that is a decision rather than a requirement. This site feature-detects and loads a small trigger-based script only where scroll timelines are unavailable and motion is still wanted, so nobody who supports the real thing downloads a single byte for it. That is the shape I would recommend if you go looking for one.
When to actually use it
The mechanism is the easy half. The harder question is whether the motion earns its place, and my answer has got stricter the longer I have built with this. The test I use is whether the animation is carrying information the still version cannot: a diagram that assembles while you read it is teaching, a photograph that drifts is giving a flat page some depth, and a heading that flies in because flying in is fun is charging a visitor attention for nothing.
A reveal on every element on the page is not a designed page, it is a default. Pick the three things worth noticing and leave the rest still. Everything I got wrong building this site was some version of that lesson.
When you are ready for the version where a section pins and holds while the animation plays, that post picks up exactly here: same two declarations, same ranges, one extra box. And if you would rather have somebody build the thing properly than spend a fortnight finding out which ancestor has an overflow on it, that is what I do. Tell me what you are trying to make move and I will tell you which timeline it wants.