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:


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.

Progressive Enhancement Layers for Scroll-Driven Animations A stacked diagram showing four layers: Baseline HTML, CSS @supports gate, JS View Transition guard, and prefers-reduced-motion reset. Each layer builds on the one below. Layer 4 β€” prefers-reduced-motion reset animation-timeline: auto !important Β· animation-duration: 0.01ms !important ALL Layer 3 β€” JS View Transition guard if (document.startViewTransition) β†’ fallback: updateDOM() Ch 111+ Β· Saf 18+ Β· FF 130+ Layer 2 β€” @supports (animation-timeline: scroll()) Scroll-driven declarations only active in supporting engines Chrome 115+ Β· Safari 17.4+ Layer 1 β€” Baseline (all browsers) Semantic HTML Β· readable Β· opacity:1 Β· no animation dependency

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:

  1. Unsupported browsers skip the entire block immediately β€” no property-invalid warnings, no FOUC.
  2. The condition is not re-evaluated after the fact (unlike media queries which respond to viewport changes).
  3. A browser that understands animation-timeline syntactically 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:

  1. Open Web Inspector β†’ Layers panel.
  2. Enable Paint Flashing β€” repainting regions flash in green.
  3. Toggle Layer Borders to confirm compositing boundaries around animated elements.
  4. Scroll rapidly while watching the Timelines panel for dropped frames.
  5. Check the JavaScript and Events timeline for requestAnimationFrame callbacks exceeding 16 ms.

Gotchas and Failure Modes

  1. Hidden content on non-supporting browsers. Setting opacity: 0 or transform: translateY(2rem) outside @supports means 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.

  2. @supports evaluates as true on Firefox with the flag. In Firefox with layout.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.

  3. animation-timeline: auto !important omitted from reduced-motion reset. The most common reduced-motion bug specific to scroll-driven animations. An element with animation-range: entry 0% entry 60% will be invisible when above the fold until scrolled into view, even if animation-duration is 0.01ms, because the timeline position still controls the interpolation.

  4. startViewTransition called without checking for existence. document.startViewTransition is undefined in Firefox < 130 and Safari < 18. A bare call without a guard throws TypeError: document.startViewTransition is not a function, breaking navigation entirely on those browsers.

  5. view-transition-name duplicated on simultaneous elements. If two elements share the same view-transition-name value during a transition, the engine throws a warning and the transition falls back to a plain crossfade. Assign unique names per element, or use view-transition-name: none to opt specific elements out.

  6. Polyfill @layer ordering. The scroll-timeline polyfill injects its own @layer declarations. If your architecture layers cascade differently, the polyfill’s specificity can lose to your existing rules. Always wrap polyfill-dependent declarations in an @layer that sits above the polyfill’s own layer.


Performance Checklist