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.

In this guide

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.

Enhancement layers over a working baseline A base layer holds the final visual state with no animation, so the page is complete for every engine. A second layer, guarded by @supports, attaches the scroll timeline. A third layer adds view transitions behind their own feature test. Removing any upper layer leaves a page that still works. Layer 3 β€” view transitions @supports (view-transition-name: none) { ... } removed: routes still swap, without the morph Layer 2 β€” scroll-driven motion @supports (animation-timeline: scroll()) { ... } removed: content appears, without the reveal Layer 1 β€” the baseline, unguarded the animation's END state written as the default: opacity 1, transform none, content readable Write the baseline first. If you write the start state as the default, an unsupported engine shows a blank page. 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.
Feature queries that lie Three feature queries compared. Testing the property name alone passes on engines that parse the property but do not implement timelines. Testing a full declaration with a real value is the reliable form. Testing an unrelated property as a proxy for engine version is the least reliable of the three. @supports (animation-timeline: auto) the initial value parses in engines that ship no timeline at all β€” this query is nearly always true @supports (animation-timeline: scroll()) tests the function you actually use β€” the only form that tracks real support @supports (container-type: inline-size) a proxy for "a recent engine" β€” it breaks the moment the two features ship apart
  1. @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.

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

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

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

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

Frequently Asked Questions

Why does my feature query pass in a browser that has no timeline support?

Almost always because the query tests a value that parses everywhere. @supports (animation-timeline: auto) asks whether the browser understands the property and its initial value, and several engines accepted the property grammar before implementing the behaviour. Test the function you actually rely on β€” @supports (animation-timeline: scroll()) β€” and the query tracks real support.

The same trap exists for view transitions. Testing view-transition-name: none is meaningful; testing that the property name is recognised is not.

Should the guard wrap the animation or the whole component’s styles?

Only the animation. A feature query is an enhancement boundary, and everything a reader needs must render before that boundary is reached. The failure this prevents is the most damaging one in this whole area: a component whose base styles live inside the guard renders as nothing at all in an unsupported engine, which is far worse than rendering without motion.

A useful discipline is to write the component completely, confirm it looks right with no animation, and only then add the guarded block. Written in that order, the mistake is structurally impossible.

Is it worth guarding at all once support is broad?

Yes, because the guard costs nothing to keep and the fallback costs something to maintain. Leaving @supports in place forever is harmless β€” it is a few bytes and it never needs revisiting. What is worth revisiting as support broadens is the fallback: a polyfill, a JavaScript replacement, or an elaborate static approximation each carries real maintenance weight, and those are the parts to delete when the compatibility data says they no longer earn it.

How do feature queries interact with the reduced-motion override?

They are independent conditions and the override must win. Support says the browser can animate; the preference says whether it should. Order them so the reduced-motion block comes after the feature query in the cascade β€” or place it in a later cascade layer β€” and the combination behaves correctly in all four states: supported and moving, supported and reduced, unsupported and static, unsupported and reduced.

The failure mode when they are ordered the other way is subtle: the page correctly honours the preference in engines without support, and ignores it in engines with support, which is precisely backwards.