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

Pages in This Section


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.

The three fallback tiers Tier one ships nothing extra and renders the animation\u2019s end state. Tier two adds an IntersectionObserver that toggles a class at a threshold, costing a few hundred bytes. Tier three loads the scroll-timeline polyfill, costing kilobytes and per-frame main-thread work. Each tier buys more fidelity at a higher price. cost on the left, fidelity on the right β€” pick the cheapest tier that still tells the story Tier 1 β€” static 0 bytes, 0 ms end state is the default Tier 2 β€” observer ~0.4 KB, one callback class toggled at a threshold Tier 3 β€” polyfill kilobytes, work every frame continuous progress restored What each tier reproduces Tier 1: the final composition. No motion, no timing, nothing to go wrong. Tier 2: the fact that something entered the viewport β€” a single on/off event, not a progress value. Tier 3: the progress value itself, recomputed in JavaScript on every scroll frame. Most reveals need only tier 1. Scrubbing effects β€” a progress bar, a pinned scene β€” are what force tier 3.
/* 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:

  • @supports is 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(), not scroll-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

  1. Elements invisible on load in legacy browsers. Setting opacity: 0 outside a @supports guard 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.
Double-running fallbacks An engine gains native support while the fallback is still shipped. The observer toggles a class that sets the end state, and the timeline animates the same property from its own progress. The two writes fight, producing a flicker at the threshold. The fix is to make the fallback exit when the feature query passes. Observer path .is-visible { opacity: 1 } set once, at the threshold writes the class on the element Timeline path animation-timeline: view() recomputed every frame animation wins over the class flicker at the threshold Gate the observer on the same feature query as the CSS, so exactly one of the two ever runs. both paths shipped, both active on the same element
  1. scroll() vs scroll-timeline: root spelling. 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()).

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

  3. FLIP scale distortion on border-radius elements. Scaling a container with border-radius produces a visible corner-radius change during the FLIP animation. Apply scale() to the element’s contents, not the container itself, or use clip-path animations instead of scale.

  4. rAF loop surviving SPA navigation. A requestAnimationFrame loop that writes --scroll-progress continues 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 via cancelAnimationFrame(rafId) in React useEffect cleanup, Vue onUnmounted, or Svelte onDestroy.

  5. void container.offsetHeight causing 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. Prefer requestAnimationFrame double-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:

  1. Chrome DevTools β†’ Application β†’ Storage β†’ check β€œOverride software rendering list”.
  2. Alternatively, disable the flag at chrome://flags/#enable-experimental-web-platform-features to suppress the API.
  3. A simpler test: temporarily add animation-timeline: auto !important to a rule that overrides your scroll-driven declarations β€” the fallback path activates without browser switching.

Profiling the fallback path:

  1. DevTools β†’ Performance β†’ CPU: 4Γ— slowdown β†’ record a full scroll cycle.
  2. Rendering tab β†’ enable Paint Flashing and Layer Borders.
  3. Confirm fallback elements are GPU-promoted (green border, not yellow warning triangle).
  4. Check that main-thread tasks during scroll stay under 50ms. Any task over 50ms during a scroll event is a blocking concern.
  5. Memory tab β†’ record before and after a full scroll β†’ check for detached DOM nodes from orphaned IntersectionObserver or 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

Fallback Strategy Decision Tree Decision tree showing: check animation-timeline support β†’ if yes, use native API; if no, check prefers-reduced-motion β†’ if reduce, show static content; if no preference, check which feature is needed β†’ scroll animation uses IntersectionObserver fallback, view transition uses FLIP technique. CSS.supports( 'animation-timeline','scroll()') true Native API animation-timeline false prefers-reduced-motion = reduce? yes Static content no animation no Which effect? scroll animation Β· view transition scroll IntersectionObserver threshold β†’ --scroll-progress CSS custom property view transition FLIP technique First β†’ Last β†’ Invert β†’ Play CSS transition on transform

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.



Up: Core Animation Fundamentals & Browser Mechanics