In short
CSS scroll-driven animations link an animation to scrolling, for progress bars and reveal effects. View transitions animate smoothly between pages and page states. What are scroll-driven animations? Normal CSS animations run on a clock: "fade in over 0.5 seconds".
CSS scroll-driven animations link an animation to scrolling, for progress bars and reveal effects. View transitions animate smoothly between pages and page states.
What are scroll-driven animations?
Normal CSS animations run on a clock: "fade in over 0.5 seconds". A scroll-driven animation runs on the scroll position instead. When the user scrolls down, the animation moves forward; when they scroll back up, it moves backwards. It is like a video where the scrollbar is the play head. You still write a normal @keyframes animation, and then connect it to scrolling with animation-timeline.
There are two timeline functions:
scroll()– progress follows how far a scroll container (by default the page) has been scrolled, from top (0%) to bottom (100%).view()– progress follows an element's journey through the visible area, from the moment it enters the screen until it leaves.
How to make a reading progress bar with CSS only
A thin bar at the top of an article that grows as you read used to need JavaScript. Now it is a few lines of CSS.
<div class="progress" aria-hidden="true"></div>
<article>
<h1>How to grow tomatoes on a balcony</h1>
<p>...a long article with many paragraphs...</p>
</article>
.progress {
display: none;
}
@supports (animation-timeline: scroll()) {
.progress {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 5px;
background: #16a34a;
transform-origin: left;
animation: grow-bar linear both;
animation-timeline: scroll(root);
}
}
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
At the top of the page the green bar has zero width. As you scroll, it grows to the right, and at the bottom of the page it fills the whole width. scroll(root) means "follow the scrolling of the whole page". The bar is hidden by default and only shown inside @supports, so browsers without the feature don't show a bar that never moves.
Common mistake: Writing
animation-timelinebefore theanimationshorthand. The shorthand resetsanimation-timelineto the normal clock, so your scroll link is lost. Always putanimation-timelineafteranimation.
Reveal-on-scroll with view()
Let's make cards fade and slide in as they come into view. We also respect users who have asked their system for less motion, using prefers-reduced-motion.
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Give any element class="reveal". As it enters the bottom of the screen, it starts invisible and 40px lower, and by the time it is 30% into the view it is fully visible in its normal place. animation-range decides which part of the journey the animation uses. Users with reduced motion turned on, and browsers without support, simply see the cards normally, with no animation.
View transitions between pages
A view transition animates the change from one view to the next. By default, the browser takes a picture of the old page, shows the new one, and cross-fades between them. For a normal multi-page website, you only need this in the CSS of both pages (they must be on the same origin):
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.4s;
}
.product-photo {
view-transition-name: product-photo;
}
Now clicking a link fades smoothly to the next page instead of flashing white. If both pages have an element with the same view-transition-name (for example a product photo on the list page and the detail page), the browser even moves and resizes that photo from its old position to its new one. Each name must be unique on a page.
For changes on the same page (like switching tabs or sorting a list with JavaScript), you wrap your DOM update in document.startViewTransition():
<button id="theme-btn">Switch theme</button>
<script>
const button = document.querySelector("#theme-btn");
const toggleTheme = () => {
document.body.classList.toggle("dark");
};
button.addEventListener("click", () => {
// Older browsers: just change the theme without animation
if (!document.startViewTransition) {
toggleTheme();
return;
}
// Modern browsers: the change is animated
document.startViewTransition(() => toggleTheme());
});
</script>
When you click the button, the old colors cross-fade into the new theme instead of switching instantly. The if check is a fallback for browsers without the feature.
Browser support and motion safety
These features are new, so always plan for browsers without them:
| Feature | Support at the time of writing |
|---|---|
| Scroll-driven animations | Chrome, Edge, Safari 26+; Firefox still in development |
Same-page view transitions (startViewTransition) | Chrome, Edge, Safari 18+, Firefox 144+ |
Page-to-page view transitions (@view-transition) | Chrome, Edge, Safari 18.2+; not yet in Firefox |
Unsupported browsers ignore the CSS, so pages still work; they just don't animate. Keep content readable without the animation: never start important text at opacity: 0 outside a feature query.
Tip: Turn off big view transitions for people who prefer less motion: wrap the
@view-transitionrule in@media (prefers-reduced-motion: no-preference). Learn more on MDN's scroll-driven animations guide.
Practice exercise
Build a long article page with a reading progress bar and five images that zoom in slightly (from scale: 0.9 to scale: 1) as they enter the screen. Then create a second page, link the two, and add a page-to-page view transition.
Hint: test in Chrome first, then open the page in Firefox to check that your fallback still looks good.
Key takeaways
animation-timeline: scroll()links an animation to scrolling;view()links it to an element's visibility.- Put
animation-timelineafter theanimationshorthand. @view-transition { navigation: auto; }cross-fades between same-origin pages.document.startViewTransition()animates DOM changes on one page.- Use
@supportsfallbacks and respectprefers-reduced-motion.