Fallback Strategies for Legacy Browsers
CSS Scroll-Driven Animations require Chrome 115+, Safari 17.4+, or Firefox with a flag enabled. The View Transitions API (same-document) requires Chrome 111+ or Safari 18+. Every other runtime needs a production fallback β otherwise scroll-driven elements stay invisible or transitions break silently. This page covers @supports gating, IntersectionObserver scroll replacements, a FLIP shim for view transitions, and a DevTools validation workflow β all within Core Animation Fundamentals & Browser Mechanics.
In this guide
- Building an IntersectionObserver Fallback for Scroll-Driven Animations β a threshold-driven progress shim for browsers without
animation-timelinesupport
Pages in This Section
- CSS Scroll-Driven Animations vs. IntersectionObserver β when native timelines outperform the observer approach
- How to Polyfill scroll-timeline for Safari β rAF-based progress mapper for Safari < 17.4
- View Transition API vs. FLIP Technique Comparison β side-by-side trade-offs and when each applies
Syntax Reference
The @supports at-rule and its JavaScript counterpart CSS.supports() are the canonical detection tools. Neither requires a polyfill of their own β both are supported in every browser that matters.
/* Positive guard β runs only where the API exists */
@supports (animation-timeline: scroll()) {
.scroll-element { animation-timeline: view(); }
}
/* Negative guard β runs everywhere else */
@supports not (animation-timeline: scroll()) {
.scroll-element { opacity: 1; transform: none; }
}
// JavaScript equivalent β same check, same result
if (!CSS.supports('animation-timeline', 'scroll()')) {
import('./scroll-fallback.js').then(m => m.init());
}
Key points:
@supportsis evaluated at parse time β unsupported browsers never see the property token, so there is no invalid-property overhead.CSS.supports()returns a boolean synchronously; it is safe to call before DOMContentLoaded.- Test
animation-timeline: scroll(), notscroll-timeline: rootβ the latter was a draft-only spelling that shipped in no browser.
Minimal Working Example
A complete fallback setup with no external dependencies. The CSS @supports block handles browsers that understand the API; the IntersectionObserver block handles everything else.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Fallback demo</title>
<style>
/* Native path */
@supports (animation-timeline: scroll()) {
@keyframes reveal {
from { opacity: 0; transform: translateY(1rem); }
to { opacity: 1; transform: none; }
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
/* Fallback path β driven by --scroll-progress custom property */
@supports not (animation-timeline: scroll()) {
.card {
opacity: var(--scroll-progress, 0);
transform: translateY(calc((1 - var(--scroll-progress, 0)) * 1rem));
transition: opacity 0.2s ease, transform 0.2s ease;
will-change: transform, opacity;
}
}
</style>
</head>
<body>
<div class="card">Content</div>
<script>
if (!CSS.supports('animation-timeline', 'scroll()')) {
const io = new IntersectionObserver(entries => {
for (const entry of entries) {
const p = Math.min(1, Math.max(0,
(entry.intersectionRatio - 0.1) / 0.8
)).toFixed(3);
entry.target.style.setProperty('--scroll-progress', p);
}
}, { threshold: Array.from({ length: 11 }, (_, i) => i / 10) });
document.querySelectorAll('.card').forEach(el => io.observe(el));
}
</script>
</body>
</html>
Fallback Scoping: Matching animation-range Semantics
Native animation-range: entry 0% entry 60% triggers the animation while an element enters the viewport from 0% to 60% intersection. The IntersectionObserver fallback should mirror this window using thresholds and ratio math.
Mapping entry 0% entry 60% to IntersectionObserver thresholds:
The denominator 0.8 in (ratio - 0.1) / 0.8 maps a 0.1β0.9 intersection band to a 0β1 progress range β approximately matching the entry 0% entry 60% keyword window. Tighten or widen it by adjusting the offset and divisor:
// entry 20% entry 80% equivalent
const progress = Math.min(1, Math.max(0,
(entry.intersectionRatio - 0.2) / 0.6
));
For exit-based ranges, reverse the mapping:
// exit 0% exit 100% equivalent
const progress = 1 - entry.intersectionRatio;
Named scroll-timeline scopes (e.g. scroll-timeline-name: --section) have no direct IntersectionObserver equivalent. The closest approximation is a root-option observer that watches a specific scroll container:
const io = new IntersectionObserver(callback, {
root: document.querySelector('.scroll-container'),
threshold: Array.from({ length: 21 }, (_, i) => i / 20)
});
Compositor-Safe Properties
The fallback must restrict itself to the same compositor-safe properties the native path uses. Writing width, height, top, or background-color in the observer callback forces a layout or paint step per frame β exactly the jank the native API avoids by running off the main thread.
| Property | Compositor-safe | Notes |
|---|---|---|
transform |
Yes | GPU-promoted via will-change: transform |
opacity |
Yes | GPU-promoted via will-change: opacity |
filter (blur) |
Partial | Composited in Chrome; not in older Safari |
clip-path |
No | Forces paint |
background-color |
No | Forces paint |
width / height |
No | Forces layout + paint |
top / left |
No | Forces layout (use transform: translate() instead) |
CSS custom property (--scroll-progress) |
N/A | Cheap write; depends what consumes it |
Set will-change: transform, opacity on elements the fallback animates. This promotes them to their own compositor layer before the first scroll event, avoiding layer-creation jank mid-scroll.
Common Implementation Patterns
Pattern 1 β Scroll-Reveal with IntersectionObserver
The most common fallback: elements enter invisibly, become visible as they scroll into view.
function initScrollRevealFallback() {
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
const progress = Math.min(
1,
Math.max(0, (entry.intersectionRatio - 0.1) / 0.8)
);
entry.target.style.setProperty(
'--scroll-progress',
progress.toFixed(3)
);
// Unobserve once fully revealed to avoid redundant callbacks
if (progress >= 1) io.unobserve(entry.target);
}
}, {
threshold: Array.from({ length: 11 }, (_, i) => i / 10)
});
document.querySelectorAll('[data-scroll-reveal]').forEach(el => io.observe(el));
}
if (!CSS.supports('animation-timeline', 'scroll()')) {
initScrollRevealFallback();
}
IntersectionObserver fires threshold callbacks off the main thread. Only the style.setProperty call lands on the main thread β and it is batched to discrete threshold crossings, not every pixel.
Pattern 2 β Sticky Progress Bar Fallback
A reading-progress bar that tracks overall page scroll position. The native approach uses animation-timeline: scroll(root). The fallback uses a passive scroll listener and writes a custom property on <html>:
function initProgressBarFallback() {
const bar = document.querySelector('.progress-bar');
if (!bar) return;
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
requestAnimationFrame(() => {
const scrolled = document.documentElement.scrollTop;
const total =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const pct = total > 0 ? (scrolled / total) * 100 : 0;
bar.style.width = `${pct.toFixed(2)}%`;
ticking = false;
});
ticking = true;
}, { passive: true });
}
if (!CSS.supports('animation-timeline', 'scroll()')) {
initProgressBarFallback();
}
The ticking guard collapses multiple scroll events that fire within the same frame into a single requestAnimationFrame call β matching the 60fps update cadence of the native compositor path.
Pattern 3 β FLIP View-Transition Shim
The View Transitions API captures before/after DOM snapshots and interpolates between them automatically. FLIP (First, Last, Invert, Play) achieves the same result with standard CSS transitions.
The critical rule: read all geometry before any write, then write, then play. Interleaving reads and writes causes forced synchronous layouts.
async function flipTransition(container, updateFn) {
// FIRST β capture geometry before mutation
const firstRect = container.getBoundingClientRect();
const firstOpacity = parseFloat(getComputedStyle(container).opacity);
// Trigger DOM update
await Promise.resolve(updateFn());
// LAST β capture geometry after mutation
const lastRect = container.getBoundingClientRect();
// INVERT β apply the delta as an instant reverse transform
const dx = firstRect.left - lastRect.left;
const dy = firstRect.top - lastRect.top;
const dw = firstRect.width / lastRect.width;
const dh = firstRect.height / lastRect.height;
container.style.transition = 'none';
container.style.transform =
`translate(${dx}px, ${dy}px) scale(${dw}, ${dh})`;
container.style.opacity = String(firstOpacity);
// Force the browser to apply the inverse state before animating
// (synchronous layout read β intentional)
void container.offsetHeight;
// PLAY β animate to the natural position
container.style.transition =
'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease';
requestAnimationFrame(() => {
container.style.transform = '';
container.style.opacity = '';
});
// Clean up after the transition
container.addEventListener('transitionend', () => {
container.style.transition = '';
}, { once: true });
}
// Usage
if (document.startViewTransition) {
document.startViewTransition(() => updateDOM());
} else {
flipTransition(document.querySelector('.card'), updateDOM);
}
Pattern 4 β Reduced-Motion Guard
Wrap all fallback initialisation in a prefers-reduced-motion check. Users who prefer reduced motion should see static content, not a JS-driven animation loop:
const prefersReducedMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!CSS.supports('animation-timeline', 'scroll()') && !prefersReducedMotion) {
initScrollRevealFallback();
initProgressBarFallback();
}
The native CSS path must include the same guard:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
animation-timeline: auto !important;
}
}
animation-timeline: auto !important resets any scroll or view timeline binding β without it, a scroll-driven animation can still progress during scroll even after animation-duration is collapsed. See implementing prefers-reduced-motion for the full pattern.
Browser Support and @supports Guard
The @supports (animation-timeline: scroll()) check is the correct guard. Do not use @supports (scroll-timeline: root) β that was a draft-era spelling that shipped in no browser.
| Browser | Scroll-Driven Animations | View Transitions (same-doc) | Notes |
|---|---|---|---|
| Chrome 115+ | Native | Chrome 111+ | Full support; both APIs compositor-threaded |
| Edge 115+ | Native | Edge 111+ | Identical to Chrome (same engine) |
| Safari 17.4+ | Native | Safari 18+ | Shipped March 2024 / Sept 2024 |
| Firefox (flag off) | Flag only | Partial (behind flag) | layout.css.scroll-driven-animations.enabled |
| Safari < 17.4 | None | None | Requires rAF polyfill; see polyfill guide |
| iOS Safari < 17.4 | None | None | Same as desktop Safari; use FLIP shim |
The browser support and progressive enhancement reference covers per-feature compat tables and the progressive-enhancement workflow in full detail.
Gotchas and Failure Modes
- Elements invisible on load in legacy browsers. Setting
opacity: 0outside a@supportsguard means legacy browsers inherit the invisible state with no JS to recover it. Always set the visible state as the baseline (opacity: 1; transform: none;) and apply the animated state inside@supports.
-
scroll()vsscroll-timeline: rootspelling. The@supports (scroll-timeline: root)test is always false in current browsers because that property spelling was dropped from the spec. Use@supports (animation-timeline: scroll()). -
IntersectionObserver unobserve omission. Without calling
io.unobserve(entry.target)when an element is fully revealed, the observer callback continues firing on every threshold crossing, accumulating redundant style writes across the page lifetime. -
FLIP scale distortion on border-radius elements. Scaling a container with
border-radiusproduces a visible corner-radius change during the FLIP animation. Applyscale()to the elementβs contents, not the container itself, or useclip-pathanimations instead ofscale. -
rAF loop surviving SPA navigation. A
requestAnimationFrameloop that writes--scroll-progresscontinues running after route changes unless explicitly cancelled in the frameworkβs unmount lifecycle. The orphaned loop writes stale progress values into the new routeβs DOM. Cancel viacancelAnimationFrame(rafId)in ReactuseEffectcleanup, VueonUnmounted, or SvelteonDestroy. -
void container.offsetHeightcausing unexpected layout in hidden containers. The synchronous layout read in the FLIP invert step forces a layout on the full document if the container is display-flex with percentage children. PreferrequestAnimationFramedouble-buffering if the container is inside a complex flex or grid context β but note that the FLIP effect becomes less precise.
Performance Checklist
DevTools Validation Workflow
Simulating a legacy browser without changing browsers:
- Chrome DevTools β Application β Storage β check βOverride software rendering listβ.
- Alternatively, disable the flag at
chrome://flags/#enable-experimental-web-platform-featuresto suppress the API. - A simpler test: temporarily add
animation-timeline: auto !importantto a rule that overrides your scroll-driven declarations β the fallback path activates without browser switching.
Profiling the fallback path:
- DevTools β Performance β CPU: 4Γ slowdown β record a full scroll cycle.
- Rendering tab β enable Paint Flashing and Layer Borders.
- Confirm fallback elements are GPU-promoted (green border, not yellow warning triangle).
- Check that main-thread tasks during scroll stay under 50ms. Any task over 50ms during a scroll event is a blocking concern.
- Memory tab β record before and after a full scroll β check for detached DOM nodes from orphaned
IntersectionObserveror rAF callbacks.
CI regression gates:
Use Playwright to drive headless Chromium with the flag disabled:
// playwright.config.js
use: {
launchOptions: {
args: ['--disable-blink-features=CSSScrollDrivenAnimations']
}
}
Assert fallback states match baseline static layouts within a 2px visual tolerance using screenshot comparison.
Fallback Decision Diagram
Frequently Asked Questions
Is a static end state really an acceptable fallback?
For most effects, it is the correct fallback rather than a compromise. A reveal animation exists to draw attention to content arriving; in an engine that cannot run it, the content is simply already there, which is the state the animation was working toward anyway. Nobody experiences that as a broken page, because there is nothing to compare it against.
The effects where a static state genuinely loses something are the ones that communicate position rather than presence β a reading progress indicator, a scrubbed sequence, a pinned scene whose whole purpose is the relationship between scroll and state. Those are the cases where a fallback with actual behaviour is worth its cost, and they are a small minority of the animation on a typical site.
Does the polyfill produce identical results to native support?
Visually close, structurally different. The polyfill recomputes progress in JavaScript on each scroll frame and writes values through the Web Animations API, so the output can match closely while the cost profile does not: everything runs on the main thread, so the animation degrades under load exactly where native support would not. It is a fidelity trade, not a free one.
That difference decides where a polyfill belongs. On a page whose main thread is otherwise quiet, the polyfilled experience is genuinely good. On an application page already competing for the main thread, the polyfill produces the stuttering the native API was adopted to avoid.
How do I stop the fallback and the native path both running?
Gate them on the same condition. If the CSS enhancement is inside @supports (animation-timeline: scroll()), the script that installs the fallback should check CSS.supports('animation-timeline', 'scroll()') and exit early when it passes. Using two different tests β a feature query in CSS and a user-agent or version check in script β is what produces engines where both paths run and fight over the same property.
The symptom of a double-run is characteristic: the effect works but flickers at the point where the fallbackβs threshold fires, because two systems are writing the same property from different sources.
Should the fallback be loaded conditionally or bundled?
Conditionally, via a dynamic import inside the negative branch of the feature test. Bundling it means every visitor downloads and parses code that most of them will never execute, which taxes exactly the modern engines that needed no help. A conditional import costs one extra request for the shrinking set of users who need it, and nothing at all for everyone else.
Related
- CSS Scroll-Driven Animations vs. IntersectionObserver β detailed performance comparison and decision matrix
- How View Transitions Work Under the Hood β snapshot model, pseudo-elements, and timing phases
- Debugging Scroll Animation Timing Functions β DevTools workflow for both native and fallback paths
- Implementing prefers-reduced-motion β full reduced-motion integration pattern