CSS Scroll-Driven Animations Without JavaScript
Scroll effects used to be one of those things that almost always meant JavaScript.
If you wanted a progress bar at the top of an article, an element that moved as the page scrolled, or an animation that started when something entered the screen, you normally had to watch the scroll position yourself.
Modern CSS can now do much of this directly with scroll-driven animations.
The main idea is simple: instead of an animation moving forward because time passes, it can move forward because the user scrolls.
A normal CSS animation
A normal animation uses time.
.box {
animation: grow 2s linear;
}
@keyframes grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
The browser starts the animation and finishes it after two seconds.
A scroll-driven animation uses scroll position as the timeline instead.
A page progress bar
This is one of the easiest examples.
Add an element:
<div class="progress"></div>
Then:
.progress {
position: fixed;
top: 0;
left: 0;
z-index: 10;
width: 100%;
height: 4px;
transform-origin: left;
animation: progress linear;
animation-timeline: scroll();
}
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
As the visitor scrolls down the page, the bar grows.
There is no scroll event listener and no JavaScript calculation.
How animation-timeline: scroll() works
Normally, an animation uses the document’s time-based timeline.
animation-duration: 2s;
With:
animation-timeline: scroll();
the scroll position becomes the timeline.
At the top of the scroll area, the animation is near its start.
At the bottom, it is near its end.
If the user scrolls backward, the animation moves backward too.
The old JavaScript way
A basic progress bar might previously use code like:
window.addEventListener("scroll", () => {
const max =
document.documentElement.scrollHeight -
window.innerHeight;
const progress = window.scrollY / max;
bar.style.transform = `scaleX(${progress})`;
});
This works, but now the browser can do the same job directly.
The MDN guide also points out that CSS scroll-driven animations can avoid some of the work that JavaScript scroll listeners put on the main thread.
Animating an element as it enters the screen
Scroll-driven animations can also use a view timeline.
Imagine a card:
<article class="card">
<h2>My project</h2>
<p>Project description.</p>
</article>
You can animate it while it moves through the viewport:
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 35%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(2rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
The animation is linked to where the card is in the visible area.
scroll() versus view()
The difference is useful to remember.
Use:
animation-timeline: scroll();
when the animation should follow the scroll position of a scrolling area.
Use:
animation-timeline: view();
when the animation should follow an element moving into or through the visible area.
A reading progress bar is a good scroll() example.
A card fading in as it enters the screen is a good view() example.
A simple image reveal
<figure class="photo">
<img src="photo.webp" alt="Street photograph in Riga">
</figure>
.photo {
animation: photo-reveal linear both;
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
@keyframes photo-reveal {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
This creates a small reveal effect without an observer script.
I would keep effects like this subtle. Scroll animations can quickly become annoying if every element flies around the page.
A horizontal progress effect
The same idea works inside a scrolling container.
<div class="slider">
<div class="item">One</div>
<div class="item">Two</div>
<div class="item">Three</div>
</div>
.slider {
display: flex;
overflow-x: auto;
}
You can create named scroll timelines for more complex cases, although for many sites the built-in scroll() and view() forms are enough.
Respect reduced motion
This matters.
Some people prefer less animation because motion can be distracting or uncomfortable.
A simple fallback is:
@media (prefers-reduced-motion: reduce) {
.card,
.photo,
.progress {
animation: none;
}
}
For a progress bar, you may choose to keep it because it gives useful information rather than decoration. For reveal effects, disabling them is often a good idea.
The main point is to think about why the animation exists.
Good uses for scroll-driven animation
I think these are good uses:
- article reading progress;
- small image reveals;
- subtle changes as a section enters the screen;
- timelines;
- data visual changes tied to scroll;
- progress inside horizontal galleries.
I would be careful with:
- large parallax effects;
- text that is hard to read until animation finishes;
- elements that move too far;
- effects on every single section.
The best scroll animation is often the one you barely notice.
Add a fallback
Not every browser version supports every part of scroll-driven animations.
The page should still make sense if the animation does not run.
That means your base CSS should be usable by itself:
.card {
opacity: 1;
transform: none;
}
Then add the effect only if supported:
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 35%;
}
}
This is a good pattern for newer CSS in general.
Do not hide content by default
One mistake is doing this:
.card {
opacity: 0;
}
and relying on the animation to make it visible.
If the animation is not supported or fails for some reason, the content may stay hidden.
It is better to keep the content visible by default and only change it inside a support check.
A safer complete example
.card {
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(1.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
}
Now the content remains usable even if the new feature is not available.
Do you still need JavaScript?
Sometimes, yes.
If an animation depends on application state, data, complex user input, or exact control that CSS cannot provide, JavaScript is still the right tool.
But for effects that are directly tied to scrolling, CSS is becoming a much better option.
A progress bar is a great example. The browser already knows the scroll position, so it makes sense to let the browser connect that position to the animation.
The useful part is not the animation
The most interesting part of scroll-driven animations is not that CSS can make things move.
CSS has been able to animate for a long time.
The useful part is that the browser now has a native way to connect animation progress to scrolling. That removes a whole layer of JavaScript that many sites used only to watch where the user was on the page.
For simple scroll effects, that can make the code much smaller and easier to maintain.