CSS Scroll-Driven Animations: No JavaScript, Full Browser Support
CSS scroll-driven animations are finally here with full browser support. Ditch the IntersectionObserver boilerplate and build scroll effects in pure CSS.
The IntersectionObserver Era Is Over
Honestly, we've been writing way too much JavaScript for something the browser should handle natively. For years, scroll-triggered animations meant pulling in GSAP ScrollTrigger, wiring up IntersectionObserver callbacks, managing cleanup in useEffect, and crossing your fingers that the timing didn't jank on low-end Android devices.
CSS scroll-driven animations — officially part of the CSS Animations Level 2 spec — hit baseline support in mid-2023 and by 2026 you'd be hard-pressed to find a meaningful browser share that doesn't support them. Chrome 115+, Firefox 110+ with a flag and full in 124+, Safari 18+. We're at real cross-browser territory now.
The core idea is simple: instead of animating based on time, you animate based on scroll position. The browser does the math. No requestAnimationFrame, no ResizeObserver, no debouncing. Just CSS.
How scroll-timeline and animation-timeline Actually Work
There are two types of scroll timelines. The scroll() function ties an animation to a scroll container — the element that's actually scrolling. The view() function ties it to an element's position within a scroll container, which is what most people want for entrance animations.
The animation-timeline property is the glue. You set it on the element you want to animate, and the browser maps the scroll progress (0% to 100%) onto your @keyframes. When the scroll position is at 0% of the timeline range, you're at the start of the keyframe. At 100% scroll progress, you're at the end.
One thing that trips people up: animation-duration becomes irrelevant when you're using a scroll timeline. You still need to declare it (or set it to auto), but the actual timing is driven entirely by scroll position. Set it to auto and move on.
Your First Scroll-Driven Animation in Pure CSS
Here's a practical example — a card that fades in and slides up as it enters the viewport. No JavaScript. No library. Just CSS.
.card {
opacity: 0;
transform: translateY(40px);
animation: fade-slide-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
@keyframes fade-slide-up {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}The animation-range property is doing the heavy lifting here. entry 0% means "when the element just starts entering the viewport" and entry 40% means "when 40% of the element's entry is complete." By the time the card is 40% into the viewport, the animation is done. It feels snappy without being jarring.
You can stack multiple animations on the same element with different ranges too. Want to fade in on entry and fade out on exit? That's two keyframe declarations on the same animation shorthand, separated by a comma, each with their own animation-range values.
Named Scroll Timelines for Complex Layouts
The anonymous view() and scroll() functions cover most cases, but sometimes you need an animation on a child element to be driven by a parent container's scroll. That's where named scroll timelines come in.
You define a named timeline on the scrolling container with scroll-timeline-name: --my-timeline and then reference it on any descendant with animation-timeline: --my-timeline. The double-dash prefix is not optional — it's part of the CSS custom property namespace that timeline names live in.
.scroll-container {
overflow-y: scroll;
height: 400px;
scroll-timeline-name: --container-scroll;
scroll-timeline-axis: block;
}
.progress-bar {
transform-origin: left;
animation: grow-bar linear both;
animation-timeline: --container-scroll;
}
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}This pattern is what you'd use for a reading progress bar, a custom scrollbar fill, or synchronizing a sticky sidebar indicator with the page's scroll position. The named timeline approach gives you that cross-element coordination without a single line of JavaScript.
Combining Scroll Animations with Tailwind v4
Tailwind v4.0.2 introduced first-class CSS custom property support and the @utility directive, which makes it easier to compose scroll animation helpers. You're not going to get animation-timeline utilities out of the box — not yet — but you can define them cleanly in your CSS layer.
The approach that works best is putting your scroll animation declarations in a @layer utilities block and then combining them with Tailwind's existing animate-* and transition-* utilities for any time-based fallbacks. For browsers that don't support scroll-driven animations (looking at older Safari), you'll want to set a fallback state with @supports not (animation-timeline: scroll()).
Don't try to put animation-timeline in arbitrary value brackets like [view()] — it doesn't parse correctly in Tailwind v4's JIT engine. A dedicated CSS file for your scroll animation utilities is cleaner anyway. Keep the concerns separate. If you're building complex animated backgrounds, see how aurora effects in React combine CSS and canvas for layered motion.
Performance: What You Actually Get for Free
Here's the thing: scroll-driven animations that only animate transform and opacity run entirely on the compositor thread. The browser never has to touch the main thread for these. No JavaScript means no risk of a long task blocking your animation. That's a meaningful win, especially on mobile.
Compare this to the old IntersectionObserver pattern where you'd toggle a CSS class in a JS callback. The class toggle itself is synchronous, but the callback fires on the main thread, which means it's subject to jank if anything else is running — a heavy re-render, a fetch response, an analytics event.
Does this mean you should scroll-animate everything? No. Animating width, height, padding, or anything that triggers layout will still cause reflows. The compositor-thread benefit only holds for transform and opacity. Stick to those two properties for your scroll animations and you'll get genuinely smooth 60fps (or 120fps on ProMotion displays) without touching a profiler.
For performance-sensitive animated UI, it's also worth looking at how particles backgrounds handle requestAnimationFrame vs CSS-native approaches — the trade-offs are similar.
Real Patterns: Progress Bars, Parallax, and Sticky Headers
A reading progress bar at the top of a page is three lines of CSS now. Attach animation-timeline: scroll(root) to a fixed element, animate its scaleX from 0 to 1, done. No state management, no resize handlers, no scroll event listeners piling up on the window object.
Parallax is trickier because the math needs to feel right. The trick is using negative animation-range values — yes, that's valid — to start the animation before the element enters the viewport. Something like animation-range: cover -20% cover 120% will create a slow drift effect as the element moves through the scroll container.
Sticky headers that change style based on scroll position? Use animation-timeline: scroll(root) with animation-range: 0px 80px and animate the background-color from rgba(0,0,0,0) to rgba(10,10,10,0.95) and the backdrop-filter blur from blur(0px) to blur(12px). The transition feels butter-smooth because there's no JavaScript threshold to cross — it's a continuous, scroll-position-driven gradient. For more on theme-aware header styling, theme toggle patterns in React cover the CSS variable side of this well.
Browser Support Reality Check and Fallback Strategy
As of late 2026, global support for animation-timeline sits around 92-93% of browsers. That's enough to ship without a JavaScript fallback in most products, depending on your audience. If you're targeting enterprise users on locked-down Chrome 110 builds, check your analytics first.
The safe fallback pattern is @supports (animation-timeline: scroll()) { ... }. Everything inside that block only runs if the browser supports it. Outside it, you define your default state — usually the final animation state so the element is visible. Never put elements in an invisible state (opacity: 0) without a fallback, or users on older browsers will see nothing.
Worth noting: the spec is still moving. animation-range shorthand behavior had a minor breaking change between Chrome 115 and 120. If you're supporting older Chrome, test your ranges. The entry, exit, cover, and contain keywords all behave slightly differently and it's easy to mix them up. Write a quick test page with colored borders on your scroll containers while you're building — it saves an hour of debugging.
FAQ
It works fine with CSS Modules. Named scroll timelines (using scroll-timeline-name) use the double-dash custom property namespace, so they'll scope correctly. Anonymous view() and scroll() functions need no scoping at all — they're relative to the element or its scroll container.
Yes, they don't conflict. Framer Motion manages its own animation loop and you can apply CSS scroll-driven animations to elements that Framer Motion isn't touching. Just don't apply both to the same property on the same element — you'll get unpredictable results as they fight over the computed value.
The entry keyword refers to the portion of the scroll range where the element is entering the viewport. If your element is taller than the viewport, entry 100% might never be reached. Try cover 0% cover 50% instead, which maps the animation across the element's full traversal of the viewport. Also double-check you haven't set animation-fill-mode: none — you usually want both.
Each element gets its own animation-timeline and animation-range declaration. There's no central orchestrator needed. If you want element B to start animating after element A has fully entered, set B's animation-range to start at a later entry percentage, or use a named scroll timeline on a parent container and use different ranges per child.
There's an unofficial polyfill at scroll-driven-animations.style maintained by Bramus Van Damme (one of the spec authors). It's good for development and low-traffic projects, but it re-introduces a JavaScript scroll listener, which defeats the performance benefit. The better production approach is @supports gating with a visible fallback state.
Not natively — CSS scroll-driven animations are inherently reversible. If you scroll back up, the animation reverses. To get a one-shot effect, you still need a small amount of JavaScript: listen for the animation event animationend and then add a class that sets the final state with transition: none. It's about 10 lines, which is far less than the old IntersectionObserver setup.