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