Building an IntersectionObserver Fallback for Scroll-Driven Animations
When animation-timeline is unavailable β stock Firefox, current stable Safari where support is still in active development, and every pre-2023 engine β the scroll-driven declarations you gated behind @supports simply never run. This page, part of Fallback Strategies for Legacy Browsers within Core Animation Fundamentals & Browser Mechanics, builds the replacement: an IntersectionObserver that detects the gap, approximates your view() entry ranges with thresholds, and toggles a class that plays an ordinary time-based CSS animation β while supporting browsers keep the pure-CSS path untouched.
One distinction up front. The question of whether to use scroll-driven CSS or IntersectionObserver as your primary technique is a design decision, covered in CSS scroll-driven animations vs IntersectionObserver. This page assumes that decision is made β native timelines win β and uses the observer only as the fallback tier for engines that cannot run them. You will build both, but only one ever executes in a given browser.
When to use this approach
The observer fallback trades scrubbed, scroll-linked progress for a triggered, time-based play. That trade is right in some situations and wrong in others:
- Entry reveals (fade-up cards, headings, images) β ideal. The native path scrubs the reveal across
animation-range: entry β¦; the fallback plays a 400β600ms animation once the element crosses an equivalent visibility threshold. Most users cannot tell the difference at reading speed. - One-shot state changes (sticky header compaction, section highlighting) β good fit. These are threshold-shaped effects even in the native API.
- Continuously scrubbed effects (parallax layers, progress bars, image sequences) β poor fit. A class toggle cannot scrub. For those, use a progress-mapping approach instead: the rAF-based mapper described in how to polyfill scroll-timeline for Safari reproduces true scroll-linked progress at the cost of more JavaScript.
- Effects that are pure decoration β consider shipping no fallback at all. The static end state from your
@supportslayering is a legitimate tier; not every browser needs motion.
The same tiering logic applies to the View Transitions side of the platform β detect, enhance, fall back β with its own detection quirks documented in detecting View Transition support with @supports and JavaScript.
Implementation
Step 1: Detect the missing feature
Gate the fallback on the same condition the CSS uses, inverted. CSS.supports() mirrors the @supports guard exactly, and a prefers-reduced-motion check belongs in the same gate β users who opted out of motion should get the static end state, not a JS-driven substitute:
const needsFallback = !CSS.supports('animation-timeline', 'scroll()');
const wantsMotion = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
).matches;
if (needsFallback && wantsMotion) {
import('./io-fallback.js').then(({ initRevealFallback }) => {
initRevealFallback();
});
}
Dynamic import() keeps the fallback bytes off the wire for Chrome and Edge users who will never run it. Keep the condition string identical to your stylesheetβs guard; the complete set of matching guard patterns β including the Firefox Nightly configuration where CSS.supports() returns true despite an incomplete implementation β is in the Browser Support & Progressive Enhancement guide.
Step 2: Author both CSS paths β native stays CSS-only
The supporting-browser path must not depend on JavaScript at all. Three blocks: an unconditional end state, the scroll-driven upgrade inside @supports, and a time-based animation keyed off a class that only the fallback script ever adds:
/* 1 β Baseline: final resting state, every browser */
.reveal {
opacity: 1;
transform: none;
}
/* 2 β Native path: pure CSS, no JS involvement */
@supports (animation-timeline: scroll()) {
@keyframes reveal-in {
from { opacity: 0; transform: translateY(1.5rem); }
}
.reveal {
animation: reveal-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
/* 3 β Fallback path: time-based, played when the observer adds the class */
@supports not (animation-timeline: scroll()) {
@keyframes reveal-in-timed {
from { opacity: 0; transform: translateY(1.5rem); }
}
.reveal.in-view {
animation: reveal-in-timed 500ms ease-out both;
}
}
Both keyframe sets declare only from, inheriting the baseline as their implicit end state, so redesigning the resting style updates all three tiers at once. Wrapping block 3 in @supports not (β¦) is belt-and-braces: even if the script ever ran in a supporting browser, the timed animation could not double-fire on top of the scroll-driven one. Restrict both paths to opacity and transform β the fallback runs through the main thread, so compositor-safe properties matter more here, not less.
Step 3: Register the observer with thresholds that approximate your entry range
animation-range: entry 0% entry 60% means the native animation completes when the element is 60% of the way through entering the scrollport. The observer equivalent: fire when intersectionRatio reaches roughly the same visibility. For elements shorter than the viewport, entry progress and intersection ratio track each other closely, so entry X% maps to a threshold near X / 100:
export function initRevealFallback() {
const targets = document.querySelectorAll('.reveal');
if (!targets.length) return null;
const io = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
// 0.6 β the native "entry 60%" completion point
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
entry.target.classList.add('in-view');
// One-shot effect: stop watching this element immediately
observer.unobserve(entry.target);
}
}
}, {
// A few coarse steps are enough for a trigger (not a scrub)
threshold: [0, 0.3, 0.6],
// Optional: start slightly early so the 500ms play finishes
// around where the native scrub would have ended
rootMargin: '0px 0px -5% 0px'
});
targets.forEach(el => io.observe(el));
return io;
}
Mapping notes:
- Short elements (height < viewport):
threshold: 0.6βentry 60%. Use a single meaningful threshold plus0; a dense 101-step array buys nothing for a class toggle. - Tall elements (height > viewport): the ratio can never reach high values β a 200vh section maxes out near 0.5. Cap the trigger threshold at something reachable (
0.2β0.4) or observe a short sentinel child instead of the tall element itself. exit-range effects: trigger onentry.isIntersecting === falseafter it wastrue, usingboundingClientRect.top < 0to distinguish exit-at-top from exit-at-bottom.- Named-timeline effects tied to an inner scroll container: pass that container as the observerβs
rootoption; the viewport-rooted default only mirrorsview()against the document scrollport.
Step 4: Toggle the class β and decide the reverse behavior
The classList.add('in-view') call in Step 3 is the whole activation mechanism, but decide deliberately what happens when the user scrolls back up:
- One-shot (recommended, matches most designs): keep the class, unobserve immediately, as above. The element stays revealed β equivalent to the native path once past its range with
animation-fill-mode: both. - Re-triggering: remove the class when the element fully leaves the viewport and skip the
unobservecall. Removing the class mid-animation cancels it abruptly, so only remove whenintersectionRatio === 0:
if (entry.intersectionRatio === 0) {
entry.target.classList.remove('in-view');
}
Note what the fallback deliberately does not attempt: reversibility during the animation. The native scrub runs backwards when the user scrolls up through the range; a time-based play cannot. That fidelity gap is inherent to the class-toggle design β if it matters for a given effect, that effect belongs with the progress-mapping polyfill approach instead.
Step 5: Clean up β unobserve, disconnect, and survive SPA navigation
Observers hold references to their targets. In a multi-page site the browser tears everything down on navigation, but in an SPA an observer left connected keeps firing against detached nodes and leaks memory. Two rules:
unobserve()each element as soon as it will never animate again (already done in Step 3 for one-shot reveals).disconnect()the whole observer when the view unmounts.
// React example β the same shape applies to Vue's onUnmounted
// and Svelte's onDestroy
useEffect(() => {
let io = null;
if (
!CSS.supports('animation-timeline', 'scroll()') &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
) {
io = initRevealFallback();
}
return () => io?.disconnect(); // runs on unmount and re-render
}, []);
If the user flips their motion preference mid-session, a change listener on the media query should call disconnect() and strip any in-view classes; the full listener pattern is in how to respect prefers-reduced-motion in CSS.
Verification
Test each tier in an environment where it is the active one:
- Native path (Chrome/Edge 115+): open DevTools β Sources β add a script breakpoint or simply block the fallback module in the Network panel. The reveal must still work β pure CSS, zero JS. If blocking the script breaks it, the native path has a hidden JS dependency.
- Fallback activation: launch Chromium with
--disable-blink-features=CSSScrollDrivenAnimations, or test in stock Firefox. ConfirmCSS.supports('animation-timeline', 'scroll()')isfalse, the module loads, and scrolling addsin-viewat the expected position. - Threshold alignment: in the fallback browser, scroll slowly and note where the animation fires; compare against the native browserβs
entry 60%completion point. Adjust the threshold orrootMarginuntil the two feel equivalent β a one-frame eyeball test catches most mismatches. - Reduced motion: emulate
prefers-reduced-motion: reducein the Rendering tab of the fallback browser. The module must not load (Step 1 gate), noin-viewclasses should appear, and content must be fully visible from the baseline tier. - Cleanup: in an SPA, navigate away and back five times, then take a heap snapshot (Memory panel) and filter for
IntersectionObserverand detached elements. Counts must stay flat. - Performance: with CPU throttled 4Γ, record a scroll through a page of reveals in the fallback browser. Observer callbacks should appear as sub-millisecond tasks at threshold crossings only β a callback firing continuously during scroll means the threshold array is doing scrub-level work a class toggle does not need.
Edge cases and gotchas
Elements already in view on load. An element above the fold reports its intersection immediately when observed β the animation plays on page load, which reads as a glitch. Either skip elements whose getBoundingClientRect().top is already inside the viewport (add in-view without animating, via a temporary animation: none marker class) or accept the initial play deliberately.
Tall elements never reach the threshold. The single most common silent failure: a threshold: 0.6 observer on a 150vh section never fires because the ratio physically cannot reach 0.6. Audit any element that can grow taller than the viewport on small screens β a card grid that stacks into one tall column on mobile is the classic case.
unobserve before the class removal logic runs. In re-triggering mode, unobserving on first reveal (copied from the one-shot recipe) means the exit crossing never fires and the class is never removed. One-shot and re-triggering recipes are mutually exclusive; do not mix their cleanup lines.
The animation shorthand vs the class toggle. If some other rule later sets animation on .reveal (a hover effect, a utility class), it overrides the fallbackβs reveal-in-timed while the specificity war is invisible in supporting browsers. Scope fallback animations to the .reveal.in-view compound selector and keep other animation utilities off observed elements.
Zoomed and resized viewports. Intersection ratios are recomputed on resize, but an in-flight 500ms animation is not. This is cosmetic for one-shot reveals; for re-triggering setups, debounce a re-sync on resize that re-checks each targetβs current intersection state.
Double-registration in hot-reload and island architectures. Calling initRevealFallback() twice creates two observers adding the same class. Make the initializer idempotent β stamp a data-io-fallback attribute on observed elements and skip stamped ones.
Browser-specific notes
Chrome / Edge 115+. The fallback module never loads (the Step 1 gate is false). Use --disable-blink-features=CSSScrollDrivenAnimations for CI coverage of the fallback tier in the same engine, as in the verification workflow above.
Safari (stable). Scroll-driven animation support is in active development (present in Technology Preview builds), so current stable Safari is the fallback pathβs largest production audience. IntersectionObserver itself has been solid in WebKit since Safari 12.1; the one quirk worth knowing is that rapid momentum scrolling can coalesce threshold callbacks, so never assume you will see every intermediate threshold β check entry.intersectionRatio against your trigger value rather than counting crossings.
Firefox (stock). animation-timeline sits behind a pref, so stock Firefox takes the fallback. Verify here rather than in Nightly: with the pref enabled, CSS.supports() reports true while the implementation is incomplete, which suppresses your fallback and may misrun the native path β the worst of both tiers.
Legacy engines. IntersectionObserver is available in all evergreen browsers and back to roughly 2017β2019 vintages. For anything older, let the gate fail silently: the baseline end state from Step 2 is the designed experience for that tier, and no additional shim is worth the bytes.
Related
- @supports Guard Recipes for animation-timeline β the CSS-side gating patterns this fallbackβs detection mirrors
- The Rendering Pipeline for Scroll Animations β why the native path runs off the main thread and the observer path does not
- Debugging Scroll Animation Timing Functions β DevTools workflow for tuning easing on both tiers
- Implementing prefers-reduced-motion β the full reduced-motion architecture the Step 1 gate plugs into