Browser Support & Progressive Enhancement for CSS Scroll-Driven Animations
CSS Scroll-Driven Animations and the View Transitions API both shipped mid-2023 to mid-2024, making them powerful but still unevenly supported. Within Core Animation Fundamentals & Browser Mechanics β the home for how browsers actually parse and execute these APIs β this section covers every technique needed to ship both features safely: correct @supports syntax, JavaScript feature guards, polyfill loading patterns, reduced-motion integration, iOS Safari workarounds, and DevTools verification.
In this guide
- @supports Guard Recipes for animation-timeline β copy-ready feature queries that gate scroll-driven styles safely on every engine
- Baseline-First CSS for Scroll Animations β the authoring order that makes a blank unsupported section impossible
Pages in this section:
- Fallback Strategies for Legacy Browsers β detailed fallback architectures,
@layerordering, and polyfill configuration
Syntax Reference
@supports conditions
| Condition | Tests for | Use when |
|---|---|---|
(animation-timeline: scroll()) |
Scroll-Driven Animations API | Guarding any animation-timeline declaration |
(view-transition-name: none) |
View Transitions API (CSS side) | Guarding view-transition-name assignments |
selector(:has(+ *)) |
:has() (often co-shipped) |
Optional: :has()-dependent reveal logic |
The not form of @supports provides an explicit opt-out path:
/* Enhancement layer β browsers that support scroll timelines */
@supports (animation-timeline: scroll()) {
.element {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
/* Baseline layer β everything else gets a static, readable state */
@supports not (animation-timeline: scroll()) {
.element {
opacity: 1;
transform: none;
}
}
JavaScript detection
// CSS.supports() mirrors @supports β always prefer this over UA sniffing
const supportsScrollTimeline = CSS.supports('animation-timeline', 'scroll()');
const supportsViewTransition = 'startViewTransition' in document;
Value arguments in CSS.supports() must be passed as separate string arguments, not concatenated into a single string (CSS.supports('animation-timeline: scroll()') also works but the two-argument form avoids quoting pitfalls in some engines).
Browser Support Matrix
| Feature | Chrome | Edge | Safari | Firefox |
|---|---|---|---|---|
animation-timeline: scroll() |
115 (Jul 2023) | 115 (Jul 2023) | 17.4 (Mar 2024) | Flag only (layout.css.scroll-driven-animations.enabled) |
animation-timeline: view() |
115 (Jul 2023) | 115 (Jul 2023) | 17.4 (Mar 2024) | Flag only |
animation-range keywords |
115 (Jul 2023) | 115 (Jul 2023) | 17.4 (Mar 2024) | Flag only |
Named scroll-timeline / view-timeline |
115 (Jul 2023) | 115 (Jul 2023) | 17.4 (Mar 2024) | Flag only |
document.startViewTransition() (same-document) |
111 (Mar 2023) | 111 (Mar 2023) | 18.0 (Sep 2024) | 130 (Oct 2024) |
view-transition-name (CSS) |
111 (Mar 2023) | 111 (Mar 2023) | 18.0 (Sep 2024) | 130 (Oct 2024) |
@view-transition (cross-document) |
126 (Jun 2024) | 126 (Jun 2024) | 18.2 (Dec 2024) | Partial (131+) |
Firefoxβs scroll-driven flag ships in Firefox 110 but the feature remains opt-in; default support is expected but not yet confirmed at time of writing. For global traffic, expect roughly 15β25% of users on engines without scroll-driven support depending on audience and region, making @supports guards non-negotiable.
Minimal Working Example
The following is self-contained β paste it into a blank HTML file and open in any browser. In Chrome 115+ / Safari 17.4+ the <h2> fades up as it enters the viewport; everywhere else it is always visible.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Scroll-Driven with Fallback</title>
<style>
body { font-family: sans-serif; padding: 2rem; }
/* Fallback β always visible, no animation dependencies */
.reveal {
opacity: 1;
transform: none;
}
/* Enhancement β gated on scroll-timeline support */
@supports (animation-timeline: scroll()) {
@keyframes fade-up {
from { opacity: 0; transform: translateY(1.5rem); }
to { opacity: 1; transform: none; }
}
.reveal {
opacity: 0;
transform: translateY(1.5rem);
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
</style>
</head>
<body>
<div style="height: 100vh; display: flex; align-items: center;">
<p>Scroll down</p>
</div>
<h2 class="reveal">This heading reveals on scroll</h2>
<div style="height: 50vh;"></div>
</body>
</html>
The baseline opacity: 1; transform: none outside @supports ensures the heading is never invisible in non-supporting browsers. The @supports block then overrides it with the animated version.
Progressive Enhancement as a Layered System
The diagram below shows how the four enhancement layers compose. Each layer adds capability without breaking the layer beneath it.
animation-range and view() Timeline Scoping
When an @supports block encloses scroll-driven declarations, scope each animation to the smallest meaningful range. Overly broad ranges (e.g. animation-range: cover 0% cover 100%) keep the animation active across the entire scroll track and can conflict with sticky or fixed elements.
@supports (animation-timeline: scroll()) {
/* Scope to the element's entry phase only */
.card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
/* Named timeline: scoped to a specific scroll container, not the root */
.gallery-track {
overflow-x: scroll;
scroll-timeline: --gallery-x x; /* horizontal axis */
}
.gallery-item {
animation: slide-in linear both;
animation-timeline: --gallery-x;
animation-range: entry 0% entry 40%;
}
}
The entry and exit keywords are part of the animation-range entry and exit keyword reference β these only exist in the scroll-driven spec and have no meaning in the CSS Animations Level 1 model, which is why the @supports guard is mandatory before using them.
Compositor-Safe Properties
Whether a property runs on the compositor thread or forces a main-thread recalculation determines whether scroll-driven animations stay smooth at 120 fps or cause jank. See the rendering pipeline for scroll animations for the full frame-budget analysis.
| Property | Compositor thread | Main-thread recalc trigger | will-change hint needed |
|---|---|---|---|
opacity |
Yes (if composited) | No | Optional (will-change: opacity) |
transform |
Yes (if composited) | No | Optional (will-change: transform) |
filter (blur, brightness) |
Partial (Chrome only) | Yes in Safari/Firefox | will-change: filter |
clip-path (simple polygon) |
No | Yes | Avoid in scroll-driven paths |
background-color |
No | Yes (triggers paint) | Avoid; use opacity + overlay instead |
width / height |
No | Yes (triggers layout) | Never animate these scroll-driven |
color |
No | Yes (triggers paint) | Avoid |
Inside @supports (animation-timeline: scroll()), restrict animated properties to opacity and transform for guaranteed compositor promotion. Apply will-change only after profiling β premature hints consume GPU memory.
Common Implementation Patterns
Pattern 1: Scroll-reveal with safe fallback
The most common pattern. The element is always visible (opacity: 1) outside @supports, then animated inside it.
/* Baseline β readable in every browser */
.reveal {
opacity: 1;
transform: none;
}
@supports (animation-timeline: scroll()) {
@keyframes reveal-in {
from { opacity: 0; transform: translateY(1rem); }
to { opacity: 1; transform: none; }
}
.reveal {
/* Safe to set hidden here β only inside the supports block */
opacity: 0;
transform: translateY(1rem);
animation: reveal-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
will-change: opacity, transform;
}
}
Pattern 2: Sticky progress indicator
A reading-progress bar that grows from 0% to 100% width as the user scrolls the page. The detailed implementation lives at creating a reading progress bar with scroll-timeline.
@supports (animation-timeline: scroll()) {
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: currentColor;
transform-origin: left center;
transform: scaleX(0);
animation: grow-bar linear both;
animation-timeline: scroll(root block);
/* No animation-range: full scroll track is intentional */
will-change: transform;
}
}
Pattern 3: View Transition with JS guard
async function navigateTo(url) {
if (document.startViewTransition) {
// Supported β animate the transition
await document.startViewTransition(async () => {
const html = await fetch(url).then(r => r.text());
updateDOM(html);
}).finished;
} else {
// Fallback β synchronous update, no crossfade
const html = await fetch(url).then(r => r.text());
updateDOM(html);
}
}
This guard pattern is explained in depth at how @view-transition works under the hood, including why startViewTransition wraps a synchronous snapshot before any DOM mutation occurs.
Pattern 4: Conditional polyfill loading
// Ship polyfill code only to browsers that need it
(async () => {
if (!CSS.supports('animation-timeline', 'scroll()')) {
const { init } = await import('./scroll-timeline-polyfill.js');
init();
}
})();
Dynamic import() keeps the polyfill out of the critical rendering path. Detailed polyfill configuration β including the @layer ordering required to prevent specificity conflicts β is at how to polyfill scroll-timeline for Safari.
Browser Support and @supports Guard
@supports evaluation timing
@supports conditions are evaluated at style parse time, not at layout or paint. This means:
- Unsupported browsers skip the entire block immediately β no property-invalid warnings, no FOUC.
- The condition is not re-evaluated after the fact (unlike media queries which respond to viewport changes).
- A browser that understands
animation-timelinesyntactically but has it behind a flag will evaluate@supports (animation-timeline: scroll())as true even if the flag is off. This is the reason Firefox nightly with the flag disabled still parses the condition as supported β test in a clean profile.
Correct vs incorrect guard syntax
/* CORRECT β tests the actual property value */
@supports (animation-timeline: scroll()) { }
/* INCORRECT β scroll-timeline is an older at-rule name, not the property */
@supports (scroll-timeline: root) { }
/* CORRECT β view-transition-name tests the CSS side */
@supports (view-transition-name: none) { }
/* INCORRECT β startViewTransition is a JS method, @supports cannot test it */
@supports (start-view-transition: true) { }
prefers-reduced-motion integration
prefers-reduced-motion does not automatically cancel a view() timeline β the element continues to track scroll position even when animation-duration is collapsed to near zero. Always reset animation-timeline explicitly:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
/* Collapse duration to near-zero β removes the visual motion */
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
/* Reset timeline so the element stops tracking scroll position entirely */
animation-timeline: auto !important;
}
}
Without animation-timeline: auto !important, a scroll-driven animation with opacity: 0 at animation-range: entry 0% will hold the element invisible until scroll passes the entry point β violating reduced-motion intent. The full prefers-reduced-motion strategy for scroll contexts is covered in implementing prefers-reduced-motion.
iOS Safari flicker mitigation
WebKitβs compositing model occasionally triggers repaint loops during rapid scroll when animation-timeline: view() is active on an element that is not already on its own compositor layer.
/* Force compositor layer promotion before animation fires */
.animated-element {
will-change: transform;
/* GPU layer hint β do not apply globally, profile first */
}
/* For the scroll container itself */
.scroll-container {
/* translate3d forces a stacking context and compositor layer */
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
/* -webkit-overflow-scrolling is obsolete in iOS 13+ β omit it */
}
Safari Web Inspector workflow for repaint diagnosis:
- Open Web Inspector β Layers panel.
- Enable Paint Flashing β repainting regions flash in green.
- Toggle Layer Borders to confirm compositing boundaries around animated elements.
- Scroll rapidly while watching the Timelines panel for dropped frames.
- Check the JavaScript and Events timeline for
requestAnimationFramecallbacks exceeding 16 ms.
Gotchas and Failure Modes
- Hidden content on non-supporting browsers. Setting
opacity: 0ortransform: translateY(2rem)outside@supportsmeans the hidden state becomes the baseline for every browser. Always set the visible/readable state as the default and apply hidden-then-animated states only inside@supports.
-
@supportsevaluates as true on Firefox with the flag. In Firefox withlayout.css.scroll-driven-animations.enabled = true,@supports (animation-timeline: scroll())returns true, but default Firefox ignores the flag and skips the block. Test in a stock Firefox install with no flags to verify the fallback. -
animation-timeline: auto !importantomitted from reduced-motion reset. The most common reduced-motion bug specific to scroll-driven animations. An element withanimation-range: entry 0% entry 60%will be invisible when above the fold until scrolled into view, even ifanimation-durationis 0.01ms, because the timeline position still controls the interpolation. -
startViewTransitioncalled without checking for existence.document.startViewTransitionisundefinedin Firefox < 130 and Safari < 18. A bare call without a guard throwsTypeError: document.startViewTransition is not a function, breaking navigation entirely on those browsers. -
view-transition-nameduplicated on simultaneous elements. If two elements share the sameview-transition-namevalue during a transition, the engine throws a warning and the transition falls back to a plain crossfade. Assign unique names per element, or useview-transition-name: noneto opt specific elements out. -
Polyfill
@layerordering. The scroll-timeline polyfill injects its own@layerdeclarations. If your architecture layers cascade differently, the polyfillβs specificity can lose to your existing rules. Always wrap polyfill-dependent declarations in an@layerthat sits above the polyfillβs own layer.
Performance Checklist
Frequently Asked Questions
Why does my feature query pass in a browser that has no timeline support?
Almost always because the query tests a value that parses everywhere. @supports (animation-timeline: auto) asks whether the browser understands the property and its initial value, and several engines accepted the property grammar before implementing the behaviour. Test the function you actually rely on β @supports (animation-timeline: scroll()) β and the query tracks real support.
The same trap exists for view transitions. Testing view-transition-name: none is meaningful; testing that the property name is recognised is not.
Should the guard wrap the animation or the whole componentβs styles?
Only the animation. A feature query is an enhancement boundary, and everything a reader needs must render before that boundary is reached. The failure this prevents is the most damaging one in this whole area: a component whose base styles live inside the guard renders as nothing at all in an unsupported engine, which is far worse than rendering without motion.
A useful discipline is to write the component completely, confirm it looks right with no animation, and only then add the guarded block. Written in that order, the mistake is structurally impossible.
Is it worth guarding at all once support is broad?
Yes, because the guard costs nothing to keep and the fallback costs something to maintain. Leaving @supports in place forever is harmless β it is a few bytes and it never needs revisiting. What is worth revisiting as support broadens is the fallback: a polyfill, a JavaScript replacement, or an elaborate static approximation each carries real maintenance weight, and those are the parts to delete when the compatibility data says they no longer earn it.
How do feature queries interact with the reduced-motion override?
They are independent conditions and the override must win. Support says the browser can animate; the preference says whether it should. Order them so the reduced-motion block comes after the feature query in the cascade β or place it in a later cascade layer β and the combination behaves correctly in all four states: supported and moving, supported and reduced, unsupported and static, unsupported and reduced.
The failure mode when they are ordered the other way is subtle: the page correctly honours the preference in engines without support, and ignores it in engines with support, which is precisely backwards.
Related
- Fallback Strategies for Legacy Browsers β
@layer-based fallback architecture and polyfill configuration - Understanding the CSS Scroll-Timeline API β
scroll()vsview()syntax, named timelines, and axis control - How @view-transition Works Under the Hood β snapshot lifecycle,
::view-transition-old/new, and why fallbacks must be synchronous - Implementing prefers-reduced-motion β full reduced-motion strategy including scroll-timeline reset patterns
- Core Animation Fundamentals & Browser Mechanics β parent section covering the rendering pipeline, compositor model, and browser mechanics