CSS Scroll Animations in 2026: @scroll-timeline, animation-timeline and View Transitions
Scroll-driven animations are now native CSS in 2026. Here's how @scroll-timeline, animation-timeline, and View Transitions actually work in production.
Scroll Animations Without JavaScript — Finally
For years, scroll animations meant IntersectionObserver, requestAnimationFrame, or pulling in a library like GSAP just to fade in a heading. That era's over. As of 2026, Chrome 115+, Firefox 132+, and Safari 18.2+ all ship native scroll-driven animations. No polyfills. No bundle bloat.
The core idea is simple: instead of time driving an animation's progress, the scroll position does. You tell the browser 'play this animation as the user scrolls from point A to point B,' and it handles the rest — including hardware acceleration. That's it.
In practice, this changes how you think about page design. Parallax effects, progress bars, reveal-on-scroll — all of these are now one or two CSS properties. Worth noting: the spec has been stable since late 2024, so you're not building on shifting sand here.
Honestly, the biggest surprise isn't the capability — it's how clean the syntax is once you get past the initial weirdness of thinking about scroll as a timeline axis.
How animation-timeline and scroll() Actually Work
The animation-timeline property is the entry point. You assign it a timeline source, and that source controls when your @keyframes run. The two main function values are scroll() and view().
scroll() ties animation progress to the scroll position of a scroll container — by default, the nearest scrollable ancestor. view() is scoped to when a specific element enters and exits the viewport. Both accept an axis argument (block or inline) and a scroller reference.
Here's a minimal progress bar that fills as the page scrolls — zero JavaScript:
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 100%;
background: linear-gradient(90deg, #6366f1, #a855f7);
transform-origin: left;
animation: grow-bar linear;
animation-timeline: scroll(root block);
}That scroll(root block) means 'use the root scroller, track the block axis.' Drop that class on a div and you're done. No event listeners. No scroll handlers. The browser does the math.
Using @scroll-timeline for Named, Reusable Timelines
The @scroll-timeline at-rule lets you define a named timeline you can reference from multiple elements — useful when you want several animations to share the same scroll range, or when you want an explicit offset window rather than the full page scroll.
Quick aside: @scroll-timeline was the original spec syntax from 2021. The newer scroll() and view() function syntax from 2023 covers most use cases more concisely, but @scroll-timeline still earns its place for complex multi-element choreography.
@scroll-timeline card-reveal {
source: selector(#card-section);
orientation: block;
scroll-offsets: 0%, 100%;
}
.card {
animation: fade-up 1s linear both;
animation-timeline: card-reveal;
}
@keyframes fade-up {
from {
opacity: 0;
translate: 0 40px;
}
to {
opacity: 1;
translate: 0 0;
}
}You can scope the timeline to any scroll container by swapping the source selector. That makes it portable — define the timeline once, apply it to as many elements as you like inside that section.
That said, browser support for @scroll-timeline specifically is slightly behind the animation-timeline shorthand, so double-check caniuse before going deep on it in production. As of June 2026, Chrome and Edge are solid; Firefox has partial support.
View Transitions: The Page-Level Animation Layer
View Transitions are a different beast. They don't animate on scroll — they animate between navigation states. Think of them as the CSS answer to the question 'why does every SPA need React-based route animation libraries?'
The API is two parts: the document.startViewTransition() JS call wraps a DOM update, and the CSS ::view-transition-old / ::view-transition-new pseudo-elements let you style the crossfade. For same-document transitions (SPA-style), that's all you need.
// React Router v7 / Next.js App Router compatible
function navigate(url) {
if (!document.startViewTransition) {
window.location.href = url;
return;
}
document.startViewTransition(() => {
window.location.href = url;
});
}/* Customise the crossfade */
::view-transition-old(root) {
animation: 200ms ease-out both fade-out;
}
::view-transition-new(root) {
animation: 300ms ease-in both fade-in;
}
@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }The cross-document version (MPA navigation) landed in Chrome 126 and only requires @view-transition { navigation: auto; } in CSS — no JavaScript at all. That's genuinely impressive for a multi-page app with zero client-side routing.
Scroll-Driven Animations + UI Components: A Practical Pattern
Where this really shines is in component libraries. If you're building or using a design system — say, the kind of layered card effects you'd find in glassmorphism components — scroll-driven animations let you encode reveal behaviour directly in the component's CSS rather than wiring up JavaScript in each consumer.
The animation-range property is the key: it lets you specify which portion of the scroll progress triggers the animation. entry 0% entry 100% means 'animate from when the element starts entering the viewport to when it's fully inside.' That's your standard scroll-reveal use case, handled entirely in CSS.
.card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes card-reveal {
from {
opacity: 0;
scale: 0.92;
translate: 0 24px;
}
to {
opacity: 1;
scale: 1;
translate: 0 0;
}
}Look, this pattern replaces the entire data-aos library for 80% of use cases. It's 10 lines of CSS. You get better performance because the browser can compositor-thread these transforms, and you get zero JavaScript overhead. If you're building interactive UIs and want inspiration for how motion integrates with visual styles, the gradient generator and box shadow generator tools are worth a look — the effects you generate there map directly into CSS keyframes.
Performance, Browser Support, and What to Watch Out For
The performance story is genuinely good. Scroll-driven animations that only animate transform, opacity, and scale run entirely on the compositor thread — the main thread isn't involved at all. That means no jank even if your JavaScript is busy.
Avoid animating height, width, top, left, or anything that triggers layout. Those force the main thread back in and you lose the perf advantage. Stick to transform-based animations and you're fine.
Browser support as of June 2026: Chrome 115+, Edge 115+, Firefox 132+, Safari 18.2+. Global coverage is around 88% without a polyfill. The @scroll-timeline polyfill from Google still works for older browsers if you need it, but for greenfield projects, progressive enhancement is probably the right call — the animations are nice to have, not load-bearing.
One more thing — prefers-reduced-motion. Always wrap your scroll animations in a media query check. Users who have that set expect no motion, not reduced motion. Kill the animations entirely for them.
@media (prefers-reduced-motion: no-preference) {
.card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}Putting It All Together in a React Component
In a React or Next.js 15 project, you'd typically colocate the CSS with the component using CSS Modules or a global stylesheet. There's no JavaScript needed for the scroll-driven part — the animation is entirely declarative.
// components/RevealCard.tsx
import styles from './RevealCard.module.css';
interface RevealCardProps {
children: React.ReactNode;
delay?: number; // in ms
}
export function RevealCard({ children, delay = 0 }: RevealCardProps) {
return (
<div
className={styles.card}
style={{ animationDelay: `${delay}ms` }}
>
{children}
</div>
);
}/* RevealCard.module.css */
@media (prefers-reduced-motion: no-preference) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
}
@keyframes reveal {
from {
opacity: 0;
translate: 0 32px;
scale: 0.95;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
}That's a fully accessible, zero-JS scroll reveal component. The delay prop handles staggered grids by offsetting the animation start. And since it uses view(), you don't need to know anything about scroll position — the browser handles all of that.
If you're building a design system or want to see how this integrates with more complex visual layers, browse the components at Empire UI — plenty of the effects there translate cleanly into scroll-driven variants using exactly this pattern.
FAQ
scroll() tracks the scroll position of a container from top to bottom. view() tracks when a specific element enters and exits the viewport — it's element-scoped, so each element gets its own timeline slice.
For straightforward reveal-on-scroll and progress indicators, no. Native CSS handles those cleanly. GSAP still wins for complex sequenced timelines, physics-based motion, or anything that needs JavaScript-driven state control.
Not out of the box — Tailwind v3 and v4 don't ship scroll-timeline utilities yet. You'd write these as custom CSS alongside your Tailwind classes, or add them via a plugin.
No, they're separate APIs that solve different problems. Scroll-driven animations tie keyframe progress to scroll position. View Transitions animate DOM state changes — typically page or route navigation crossfades.