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.
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
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