CSS Scroll-Driven Animations: When animation-timeline Breaks Your Layout
The animation shorthand silently resets your scroll timeline. Plus: main thread demotion, inactive timelines, and stacking context surprises.
CSS scroll-driven animations let you tie @keyframes to scroll position instead of time.
The API is elegant — animation-timeline: scroll() or view() and you're done.
But beneath that simplicity lie sharp edges that will burn you in production.
The Shorthand Reset Trap
This is the single most common bug. The animation shorthand resets animation-timeline to auto.
Unlike every other animation sub-property, you cannot set animation-timeline via the shorthand —
it's a "reset-only sub-property." Declaration order matters:
/* ❌ BROKEN: animation resets timeline to auto */
.progress {
animation-timeline: scroll();
animation: grow-bar linear forwards;
}
/* ✅ WORKS: declare timeline AFTER the shorthand */
.progress {
animation: grow-bar linear forwards;
animation-timeline: scroll();
}
/* ✅ ALSO WORKS: skip the shorthand entirely */
.progress {
animation-name: grow-bar;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-timeline: scroll();
}
The same applies to animation-range and animation-range-start/animation-range-end.
If you refactor to the shorthand later, your scroll animation silently stops working. No warnings, no errors — just a time-based animation that runs once and you're left debugging ghosts.
Main Thread Demotion
One of the biggest selling points of scroll-driven animations is compositor-thread execution — no jank, no layout thrashing.
But this only applies to properties the compositor can handle: transform, opacity, filter,
clip-path, and background-color (in some engines).
The moment you animate a layout-triggering property, the entire animation drops to the main thread:
/* ❌ Main thread — animates font-size (layout trigger) */
@keyframes sticky-header {
from { height: 100vh; font-size: 4vw; }
to { height: 10vh; font-size: 1.2rem; }
}
/* ✅ Compositor thread — transform only */
@keyframes sticky-header {
from { transform: scale(1); }
to { transform: scale(0.3); }
}
height, width, font-size, padding, margin,
top/left, and custom properties (--var) all force main-thread execution.
You won't see a warning — the animation just becomes janky under load. Profile with DevTools' "Animations" panel
and check which thread handles the frames.
The Inactive Timeline Problem
A scroll timeline requires a scroll container with actual overflow.
If the content doesn't overflow — or overflow is set to visible or hidden
(not scroll or auto) — the timeline becomes inactive and the animation simply doesn't run.
/* ❌ No scrollbar = inactive timeline */
.container {
overflow: hidden; /* clips but doesn't scroll */
}
.child {
animation: fade-in linear both;
animation-timeline: scroll(nearest);
/* Walks up to .container, finds no scroller, timeline inactive */
}
/* ✅ Ensure the scroller actually scrolls */
.container {
overflow-y: auto;
max-height: 80vh;
}
This is especially treacherous with scroll(nearest) (the default). If a parent gains or loses
overflow: hidden through a media query or JS toggle, your scroll animation silently breaks.
Prefer scroll(root) when animating against the page scroll to avoid ancestor dependency.
view() and Stacking Contexts
animation-timeline: view() tracks element visibility within its nearest scroll container's scrollport.
But the animation itself can create an implicit stacking context — and that changes
rendering order in ways you didn't plan for.
Any element with a running CSS animation gets a new stacking context (per spec). This means:
z-indexvalues on children are now relative to the animated element, not the page- Fixed-position descendants may clip unexpectedly
- Dropdowns or tooltips behind the animated element can disappear
/* This card animates on scroll... */
.card {
animation: reveal linear both;
animation-timeline: view();
/* ⚠️ Implicit stacking context created here */
}
/* ...but its tooltip now clips under the next card */
.card .tooltip {
position: fixed; /* Still constrained by the stacking context */
z-index: 9999; /* Doesn't escape the parent's context */
}If you need overlapping animated elements with tooltips or popovers, use the Popover API (top layer) or hoist the overlay outside the animated subtree.
animation-range Gotchas
animation-range lets you narrow when the animation starts and ends within the timeline.
The named ranges — cover, contain, entry, exit,
entry-crossing, exit-crossing — only apply to view timelines.
Using them with scroll() silently does nothing:
/* ❌ Named ranges are ignored with scroll() */
.element {
animation: fade linear both;
animation-timeline: scroll();
animation-range: entry 0% entry 100%;
/* Treated as 'normal' — full scroll range */
}
/* ✅ Named ranges work with view() */
.element {
animation: fade linear both;
animation-timeline: view();
animation-range: entry 0% cover 50%;
}
Also watch out for animation-range with position: sticky elements.
The range calculation uses the element's position before the sticky offset is applied,
so the visual trigger point won't match what you expect.
Browser Support: The Feature Detection Dance
As of early 2026: Chrome 115+, Edge 115+, Safari 26 beta, Firefox behind a flag.
But checking for animation-timeline: scroll() alone isn't enough — Firefox Nightly supports
the property partially but not animation-range. The robust check:
@supports (
(animation-timeline: scroll()) and
(animation-range: 0% 100%)
) {
/* Full scroll-driven animation support */
}
@supports not (animation-timeline: scroll()) {
/* Fallback: IntersectionObserver + WAAPI or static styles */
}
For progressive enhancement, use the
scroll-timeline polyfill
or fall back to IntersectionObserver with element.animate().
Design animations as enhancements — the page should work without them.
Key Takeaways
- Always declare
animation-timelineafter theanimationshorthand - Stick to compositor-friendly properties (
transform,opacity) to stay off the main thread - Ensure the target scroller has actual overflow —
overflow: hiddenkills the timeline - Remember that animated elements create stacking contexts — plan your z-index accordingly
- Named ranges (
entry,cover, etc.) only work withview(), notscroll() - Feature-detect both
animation-timelineandanimation-rangefor reliable support checks
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Further Reading
Unleash the Power of Scroll-Driven Animations — CSS-Tricks
Comprehensive walkthrough of Bramus's video series on scroll-driven animations
A Guide to Scroll-Driven Animations — WebKit
Safari's implementation guide with examples and edge cases
Scroll-Driven Animation Performance Case Study — Chrome
Performance comparison between JS scroll listeners and CSS scroll-driven animations
Related Insights
Explore related edge cases and patterns
Advertisement