CSS Scroll-Driven Animations Advanced: view(), scroll() and Named Timelines
Master CSS scroll-driven animations with view(), scroll(), and named timelines - no JavaScript, no libraries, just native browser APIs doing the heavy lifting.
Why Scroll-Driven Animations Are Finally Native
For years, scroll animations meant one thing: shipping a JavaScript dependency. GSAP's ScrollTrigger, Intersection Observer hacks, window.addEventListener('scroll', ...) wired to requestAnimationFrame - it all worked, but it meant JS on the main thread, bundle weight, and a constant battle against jank. Not anymore. Chromium 115 (mid-2023) shipped the CSS Scroll-Driven Animations spec natively, and by 2026 it's in Firefox 110+ and Safari 18+ too. You now have a complete, production-grade scroll animation system that runs entirely off the main thread.
The spec gives you two animation timeline types. scroll() ties an animation's progress to the scroll position of a scroll container. view() ties it to an element's visibility within the viewport. Both plug into the existing animation-timeline CSS property, which means you can combine them with ordinary @keyframes, animation-duration, animation-fill-mode - everything you already know. That said, the mental model is genuinely different from time-based animation, and it trips people up at first.
Honestly, the biggest win isn't the zero-JS part. It's that the browser compositor thread drives these animations. Even when your main thread is choked parsing a 400kb bundle, scroll-driven animations keep moving at 120fps. That's not something you can replicate with scroll event listeners no matter how carefully you debounce.
If you're already doing CSS keyframe animations or experimenting with GSAP in React, scroll-driven animations aren't a replacement for everything - but for element entry effects, progress indicators, and parallax, they're now the right default.
scroll() - Tying Animation to a Scroll Container
The scroll() function creates a ScrollTimeline - an animation timeline whose progress maps 0% to 100% based on how far a scroll container has been scrolled. The simplest use case is a reading progress bar pinned to the top of your page.
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, #7c3aed, #ec4899);
transform-origin: left;
/* The magic */
animation: grow-bar linear;
animation-timeline: scroll(root block);
}scroll() takes two optional arguments: the scroller (root, nearest, or a named container - more on that below) and the axis (block or inline). root is the document scroller; nearest finds the closest scrollable ancestor. You almost always want block (vertical scroll) unless you're doing horizontal carousels.
Worth noting: animation-duration is irrelevant here. You can set it to auto - that's actually the spec-correct value when using a scroll timeline. If you write an explicit duration like 1s, the browser ignores it and uses scroll position instead. Drop the duration or set it to auto and stop fighting the spec.
One more thing - animation-fill-mode matters a lot. Set both on most scroll-driven animations so the start state applies before the user scrolls and the end state sticks after they scroll past. Without it, elements pop back to their default state at scroll position 0 and 100%.
view() - Animations Triggered by Element Visibility
view() is where most developers will spend their time. It creates a ViewTimeline whose progress maps to how far an element has travelled through the viewport. At 0% the element is about to enter; at 100% it's fully exited the other side. The sweet spot for entry animations is the entry range: 0% to 100% of the entry phase.
@keyframes fade-up {
from {
opacity: 0;
translate: 0 40px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.card {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}The animation-range property is the key to controlling exactly *when* in the view timeline the animation plays. The named ranges are entry, exit, contain, and cover. entry 0% entry 40% means "animate from the moment the element starts entering the viewport until 40% of the entry phase is complete" - which gives you a snappy 0-to-40px fade-up as the card scrolls in. In practice, entry 0% entry 50% is the sweet spot for most card reveals.
view() also accepts an inset argument: view(20%) shrinks the effective viewport by 20% on both ends, so animations fire later (after the element is more visible). It's equivalent to Intersection Observer's rootMargin but expressed as a percentage of the scroller's dimension. Pair this with the intersection-observer-react patterns you might already know - they're complementary, not competing tools.
Quick aside: view() only works on elements inside a scrollable container. If your element lives in a non-scrollable wrapper with overflow: hidden, the animation won't fire. Always check that the scroll container chain reaches the document root or a named scroll container you've defined.
Named Scroll Timelines: scroll-timeline-name and view-timeline-name
Here's where it gets interesting. The anonymous scroll() and view() functions are convenient, but they're limited - they always reference the nearest scroller or the element's own position. Named timelines let you decouple the *source* of the timeline from the *element* that animates. This unlocks complex multi-element choreography.
/* 1. Register a named scroll timeline on the scroller */
.hero-section {
overflow-y: scroll;
scroll-timeline-name: --hero;
scroll-timeline-axis: block;
}
/* 2. A completely separate element uses it */
.sticky-label {
position: sticky;
top: 24px;
animation: slide-in linear both;
animation-timeline: --hero; /* references the named timeline */
animation-range: 0% 30%;
}
@keyframes slide-in {
from { translate: -100% 0; opacity: 0; }
to { translate: 0 0; opacity: 1; }
}Named view timelines work the same way via view-timeline-name on the observed element and animation-timeline: --your-name on whatever animates in response. The dash-dash prefix (CSS custom property syntax) is mandatory - the spec requires it to avoid collisions with standard keywords.
Look, the named timeline system is what separates the CSS scroll spec from a toy. You can drive an entire page section's choreography - sticky headers, parallax layers, progress indicators, stagger-delayed cards - all from a single scroll container, with zero JavaScript. It's the same pattern that GSAP ScrollTrigger's scrub enables, but entirely declarative. Check out how Empire UI's aurora components use layered animations - the underlying technique maps directly onto named timelines.
One caveat worth calling out: named timelines are scoped to their containing element's subtree. An element inside a shadow DOM can't reference a named timeline from outside it. If you're building web components, you'll need to mirror the timeline across the shadow boundary - awkward, but workable with CSS custom properties set on the host.
Advanced Patterns: Staggered Entry and Parallax Layers
Staggering card entries with scroll-driven animations requires animation-delay - but with a twist. On scroll timelines, animation-delay is expressed as a percentage or a <length> in scroll pixels (not milliseconds). The cleanest approach is to use custom properties and @property to drive per-card offsets.
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.grid .card {
--stagger: 0;
animation: fade-up linear both;
animation-timeline: view();
animation-range:
calc(entry 0% + var(--stagger) * 1px)
calc(entry 50% + var(--stagger) * 1px);
}
.grid .card:nth-child(2) { --stagger: 60; }
.grid .card:nth-child(3) { --stagger: 120; }This shifts each card's animation range by 60 scroll pixels (60px and 120px respectively), producing a natural waterfall stagger as the grid scrolls into view. You won't get pixel-perfect behaviour across every device because viewport height affects when elements enter - but for decorative stagger it's fine. For pixel-perfect control, fall back to GSAP or Framer Motion.
Parallax is where scroll-driven animations genuinely shine against JavaScript solutions. Two elements, same scroll() timeline, different animation-range end points - the one that finishes at 60% moves 40% slower relative to scroll than the one that finishes at 100%. No division by viewport height, no resize listeners, no math.
@keyframes parallax-slow {
to { translate: 0 -80px; }
}
@keyframes parallax-fast {
to { translate: 0 -200px; }
}
.bg-layer { animation: parallax-slow linear both; animation-timeline: scroll(); }
.fg-layer { animation: parallax-fast linear both; animation-timeline: scroll(); }Pair this with the glassmorphism hero patterns if you want that layered frosted-glass depth - glass cards on a parallax background look exceptional with scroll-driven motion behind them. You can also pull ready-made animated background components from Empire UI and wire them to a named scroll timeline with six lines of CSS.
Browser Support, Fallbacks, and @supports
As of 2026, animation-timeline: scroll() and animation-timeline: view() have around 88% global browser support. That's good enough for most production projects, but you still want a fallback for the remaining 12%. The correct pattern is @supports.
.card {
/* Default: visible, no animation */
opacity: 1;
translate: 0 0;
}
@supports (animation-timeline: scroll()) {
.card {
opacity: 0;
translate: 0 40px;
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
}This is the safest pattern: show content by default, enhance with animation when the browser supports it. Never hide content inside the @supports block without a plain-CSS fallback - you'll break the experience for Safari 17 and older Firefox users who are still out there.
Named timelines (scroll-timeline-name, view-timeline-name) can be feature-detected separately if you need them in isolation: @supports (scroll-timeline-name: --test). In practice, if animation-timeline: scroll() is supported, named timelines are too - they shipped together in every engine.
Worth noting: the animation-range property itself can be tested with @supports (animation-range: entry 0%). If you're writing component library code (like the Empire UI component system), guard each feature independently so older browsers degrade gracefully rather than silently breaking.
Debugging Scroll-Driven Animations in DevTools
Chrome DevTools 115+ has a dedicated Animations panel that understands scroll-driven timelines. Open DevTools, go to the three-dot menu β More tools β Animations. You'll see scroll timelines listed alongside time-based ones, and you can scrub them manually by dragging the scroll progress slider. This makes debugging animation-range offsets dramatically faster - you'd otherwise have to physically scroll to each position and guess.
The Elements panel also shows animation-timeline in the Computed styles section, and mousing over a timeline value highlights the scroll container it references with a blue overlay. If view() isn't firing, the most common culprit is a parent with overflow: hidden breaking the scroll container chain - DevTools surfaces this with a yellow warning triangle next to the property.
For named timelines that span multiple elements, the Layers panel is your friend. Each element with an active scroll-driven animation gets its own compositor layer (you'll see it in the layer tree). Too many active timelines on the same page = too many layers = memory pressure. As a rule of thumb, keep active scroll-driven animations under 20 per page, and prefer will-change: transform, opacity only on elements that are actively animating, not on every .card in the DOM.
One more thing - if you're using React and dynamically mount elements, scroll-driven animations attach on element paint. There's no animationstart race condition the way there is with setTimeout-based entrance effects. The browser attaches the timeline at render time and the animation just works. That's a genuinely nice developer experience compared to auto-animate-react or manual Intersection Observer setups.
FAQ
scroll() maps animation progress to a scroll container's scroll position - great for progress bars and parallax. view() maps progress to an element's position within the viewport - what you want for entry/exit effects as elements scroll into view.
Yes, entirely. The browser's compositor thread handles them natively - no JS, no libraries, no event listeners required. Support lands in Chromium 115+, Firefox 110+, and Safari 18+.
animation-range controls which portion of the timeline triggers the animation, using named range keywords like entry, exit, contain, and cover. For example entry 0% entry 50% runs the animation during the first half of the element's entry into the viewport.
Use named scroll timelines: set scroll-timeline-name: --my-name on the scroll container, then reference animation-timeline: --my-name on the element you want to animate - even if it's elsewhere in the DOM.