Skip to content

css-scroll-driven/scroll-anim-fallback

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

scroll-anim-fallback

Write CSS scroll-driven animations once. Ship them to every browser.

scroll-anim-fallback is a two-part toolkit. A PostCSS plugin reads the scroll-driven animations you already wrote, guards them behind @supports (animation-timeline: scroll()), generates a companion fallback layer, and extracts a JSON manifest describing every timeline it found. A dependency-free runtime consumes that manifest and reproduces the animations with IntersectionObserver and a throttled scroll driver — but only in browsers that need it. Everywhere else it exits after a single CSS.supports() call.

You keep authoring the native syntax. Nothing in your stylesheet changes shape, and the day your support baseline moves you delete one <script> tag.


Contents


Why

CSS scroll-driven animations are the first way to tie an animation's progress to a scroll position entirely off the main thread. animation-timeline: view() and animation-timeline: scroll() replace whole categories of JavaScript, and the browser can run them on the compositor.

The catch is the failure mode. animation-timeline is not a property a browser can partially understand. A browser that does not implement it drops the declaration and is left with everything else in the rule — which usually means animation: fade-up 1s both, a time-based animation that fires immediately on page load, in the wrong place, all at once. The elegant scroll effect becomes a flash of content on first paint.

The usual answers each cost something:

Approach Cost
Ship a full ScrollTimeline polyfill Tens of kilobytes, loaded by every browser including the ones with native support
Hand-write @supports blocks and a parallel JS path Two implementations of the same animation, drifting apart over time
Don't animate in unsupported browsers Layout that assumes the animation ran (elements stuck at opacity: 0)
Skip scroll-driven animations entirely You give up the compositor

This toolkit takes a fourth position: derive the fallback from the CSS you already wrote, at build time, and ship a runtime small enough that its cost in a modern browser is a feature query. See fallback strategies for legacy browsers for a wider survey of the trade-offs.


How it works

flowchart TD
    A["source.css<br/>animation-timeline: view()"] --> B[PostCSS plugin]
    B --> C["@supports (animation-timeline: scroll())<br/>your original rules, untouched"]
    B --> D["@supports not (animation-timeline: scroll())<br/>generated fallback rules"]
    B --> E["manifest.json<br/>selector, timeline, range, keyframes"]
    C --> F[output.css]
    D --> F
    E --> G[Runtime]
    F --> H{{"CSS.supports('animation-timeline: scroll()')"}}
    G --> H
    H -->|true| I["Native compositor animation.<br/>Runtime returns false and allocates nothing."]
    H -->|false| J{Per-timeline mode}
    J -->|observer| K["IntersectionObserver toggles .saf-in-view<br/>CSS transition does the work"]
    J -->|progress| L["passive scroll + rAF → progress 0-1<br/>seeks a paused WAAPI animation<br/>and writes --saf-progress"]
Loading

Three ideas carry the whole design:

  1. The native rule is never rewritten. It is moved, verbatim, inside a feature query. Browsers with support see exactly the CSS you authored.
  2. The fallback is derived, not written. The plugin reads the @keyframes the animation refers to and synthesises the start state, the end state, and the transition between them. There is no second source of truth.
  3. The runtime is data-driven. It never parses CSS and never reads stylesheets. Everything it needs — selectors, ranges, keyframe offsets, durations — arrives in a JSON manifest extracted at build time, so the runtime is small and the parsing cost is paid once, by your build.

The two fallback modes

The plugin picks a mode per timeline.

observer — chosen when both range boundaries name the same one-shot range (entry, exit, entry-crossing, exit-crossing). These are conceptually "play once when the element arrives", which an IntersectionObserver models perfectly and cheaply. The generated CSS holds the start state and transitions to the end state when the runtime adds .saf-in-view. No scroll listener runs. This is the ideal outcome and covers most reveal-style effects. The trade-offs are discussed further in IntersectionObserver fallback for scroll animations.

progress — chosen for scroll() timelines and for any range that spans continuously (cover, contain, or a mix of two different named ranges). Here the animation must track the scroll position, not merely react to a threshold. The runtime attaches one passive scroll listener per scroll container, coalesces every event into a single requestAnimationFrame callback, computes progress from cached geometry, and then — crucially — seeks a paused Web Animations API animation rather than writing styles from JavaScript. Setting animation.currentTime hands interpolation back to the browser's animation engine, so the fallback stays close to native behaviour instead of thrashing inline styles every frame. Progress is also mirrored into --saf-progress so your own CSS can read it.


Install

This project is not published to npm. There is no registry package named scroll-anim-fallback; install it from a clone.

git clone https://github.com/css-scroll-driven/scroll-anim-fallback.git
cd scroll-anim-fallback
npm install
npm test
npm run examples   # builds the demo stylesheets

Using the PostCSS plugin in another project

Point a dependency at the local checkout:

cd ~/my-project
npm install ../scroll-anim-fallback/packages/postcss-scroll-anim-fallback

or declare it in your package.json with a file: specifier:

{
  "devDependencies": {
    "postcss": "^8.4.47",
    "postcss-scroll-anim-fallback": "file:../scroll-anim-fallback/packages/postcss-scroll-anim-fallback"
  }
}

Both forms work with npm, pnpm and Yarn. A git specifier ("postcss-scroll-anim-fallback": "github:css-scroll-driven/scroll-anim-fallback#main") will not resolve to the subdirectory, so prefer a path.

Using the runtime in another project

The runtime is a single dependency-free file with no build step of its own to integrate. Build it once and copy it:

npm run build   # writes packages/scroll-anim-fallback-runtime/dist/
cp packages/scroll-anim-fallback-runtime/dist/scroll-anim-fallback.umd.js ~/my-project/public/js/
  • dist/scroll-anim-fallback.umd.js — UMD/IIFE build for a plain <script> tag; exposes window.ScrollAnimFallback.
  • dist/esm/index.js — ES modules, for a bundler or a native <script type="module">.

You can also add the workspace package as a file: dependency exactly like the plugin, and import from scroll-anim-fallback-runtime.


PostCSS plugin

// postcss.config.mjs
import scrollAnimFallback from 'postcss-scroll-anim-fallback';

export default {
  plugins: [
    scrollAnimFallback({
      manifest: './public/scroll-anim-manifest.json'
    })
  ]
};

Programmatic use, including reading the manifest without writing it to disk:

import postcss from 'postcss';
import scrollAnimFallback from 'postcss-scroll-anim-fallback';

const result = await postcss([scrollAnimFallback()]).process(css, { from: 'src/app.css' });
const { manifest } = result.messages.find((m) => m.type === 'scroll-anim-fallback-manifest');

The plugin runs in OnceExit, so it sees the stylesheet after every other plugin has finished. Put it last.

Options

Option Type Default Description
supportsWrap boolean true Move each scroll-driven rule inside @supports (animation-timeline: scroll()). Set to false if you already hand-wrote your guards and only want the fallback layer and manifest.
fallbackMode 'observer' | 'progress' | 'none' 'observer' 'observer' lets the plugin choose per timeline between the class-toggle and progress strategies. 'progress' forces the progress driver everywhere. 'none' emits no fallback CSS at all but still produces the manifest, which is useful if you want to drive the fallback yourself.
manifest string Filesystem path to write the manifest JSON to. Parent directories are created. The manifest is attached to result.messages whether or not this is set.
classPrefix string 'saf' Prefix for generated classes (.saf-in-view), the progress custom property (--saf-progress), the transition tuning properties (--saf-duration, --saf-easing) and manifest ids. Change it if saf collides with something in your codebase.
preserveOriginal boolean false Keep an unguarded copy of the source rule alongside the guarded one. Only useful when another tool downstream needs to see the original declarations.

Which declarations are detected

A rule is treated as scroll-driven if it declares any of:

animation-timeline, scroll-timeline, scroll-timeline-name, scroll-timeline-axis, view-timeline, view-timeline-name, view-timeline-axis, view-timeline-inset, animation-range, animation-range-start, animation-range-end, timeline-scope.

Idempotency

Running the plugin twice over the same stylesheet produces the same output. A rule that already sits inside an @supports at-rule whose parameters mention animation-timeline is never wrapped again — including guards you wrote by hand, in any of the forms covered by @supports guard recipes for animation-timeline. Likewise there is only ever one @supports not (...) fallback layer per stylesheet; a second pass appends into the existing one.

Tuning the generated transition

observer-mode CSS transitions using two custom properties, so you can tune timing per element without touching the build:

.reveal { --saf-duration: 900ms; --saf-easing: cubic-bezier(.2, .8, .2, 1); }
.reveal--slow { --saf-duration: 1.6s; }

Runtime

<link rel="stylesheet" href="/css/app.css">
<script src="/js/scroll-anim-fallback.umd.js"></script>
<script>
  fetch('/scroll-anim-manifest.json')
    .then((response) => response.json())
    .then((manifest) => ScrollAnimFallback.init({ manifest }));
</script>

As ES modules:

import { init } from 'scroll-anim-fallback-runtime';
import manifest from './scroll-anim-manifest.json' with { type: 'json' };

init({ manifest, reducedMotion: 'end' });

If you want to avoid a network round trip in browsers that will not use the manifest anyway, gate the fetch:

if (!CSS.supports('animation-timeline: scroll()')) {
  const manifest = await fetch('/scroll-anim-manifest.json').then((r) => r.json());
  ScrollAnimFallback.init({ manifest });
}

API

Function Signature Description
init init(options) → boolean Starts the runtime. Returns false immediately (having allocated nothing) when native support is present, true when the fallback was activated. Throws a TypeError if manifest is missing. Calling init again tears down the previous session first.
destroy destroy() → void Stops the runtime, disconnects every observer and listener, cancels every generated animation, and removes every class and custom property it applied.
refresh refresh() → boolean Re-queries the DOM with the same options and rebuilds all bindings. Call it after inserting or removing content that matches a manifest selector. Returns false if the runtime is not active.
observe observe(element, config) → () => void Binds a single element to a manifest-shaped config by hand, for elements created after init or timelines the plugin could not infer statically. Returns a disposer.
supportsNative supportsNative() → boolean The feature test the runtime uses internally.

The module also re-exports every function from range mathresolveNamedRange, resolveRange, progressAt, viewProgress and friends — so you can reuse the calculations directly.

Options

Option Type Default Description
manifest object required The parsed manifest document.
root ParentNode document Subtree to run querySelectorAll against. Scope the runtime to a widget or a shadow root by passing it here.
reducedMotion 'end' | 'ignore' | 'none' 'end' Behaviour when prefers-reduced-motion: reduce matches. 'end' jumps every element straight to the animation's end state and attaches no listeners — content is fully visible, no motion occurs. 'ignore' runs the fallback normally, appropriate for animations that convey information rather than decoration (a reading progress bar, for instance). 'none' attaches nothing and leaves elements in their start state.
force boolean false Run the fallback even where native support exists. For testing only — see Testing the fallback path.

The reduced-motion preference is re-evaluated live: the runtime listens for change on the media query and tears down or re-attaches its drivers when the user flips the OS setting mid-session. Background on the preference itself is in respecting prefers-reduced-motion in CSS.

Performance characteristics

  • Native browsers: one CSS.supports() call. No listeners, no observers, no allocations, no DOM queries.
  • observer timelines: one IntersectionObserver per timeline. Nothing runs during scroll.
  • progress timelines: one passive scroll listener per scroll container (not per element) and at most one requestAnimationFrame callback per frame for all bindings combined. Geometry is measured lazily, cached, and invalidated by a ResizeObserver rather than re-measured per frame, so a scroll frame performs no forced layout.
  • Progress is rounded to four decimal places before being written, so a sub-pixel scroll does not churn style recalculation.

The manifest format

The manifest is the contract between the two halves of the toolkit. It is plain JSON and stable enough to generate or hand-write yourself.

{
  "version": 1,
  "classPrefix": "saf",
  "timelines": [ /* one entry per detected rule */ ]
}

Top level

Field Type Description
version number Schema version. Currently 1.
classPrefix string The prefix the plugin used, echoed for tooling.
timelines array Timeline entries, in document order.

Timeline entry

Field Type Description
id string Stable identifier, "<classPrefix>-<n>", unique within the manifest.
selector string The selector the animation was declared on, verbatim.
timeline.kind 'scroll' | 'view' | 'named' | 'none' | 'auto' How the timeline is sourced.
timeline.name string | null The custom timeline name when kind is 'named', e.g. "--hero".
timeline.axis 'block' | 'inline' | 'x' | 'y' Axis the timeline tracks. Logical axes are resolved against the element's writing mode at runtime.
timeline.scroller 'nearest' | 'root' | 'self' Scroll container selection for scroll().
range.start.name string Named range of the start boundary; defaults to "cover".
range.start.percent number Percentage offset within that named range. May fall outside 0–100.
range.end.name string Named range of the end boundary.
range.end.percent number Percentage offset within that named range.
animationName string | null Animation name from animation-name or the animation shorthand.
keyframes string | null Name of the @keyframes the fallback was derived from; null when no matching @keyframes exists.
steps array Normalised keyframe steps: { offset: 0–1, declarations: { property: value } }, sorted by offset. from/to/percentage selectors are all normalised, and comma-separated selectors are expanded.
duration number Synthetic duration in milliseconds for the WAAPI animation the runtime seeks. Taken from the animation shorthand or animation-duration, defaulting to 1000. The value is arbitrary — progress maps onto it linearly — but keeping the authored value makes debugging easier.
mode 'observer' | 'progress' | 'none' Which fallback strategy the runtime should use.
classes.inView string Class the runtime toggles in observer mode.
classes.progress string Class name reserved for progress-driven elements.
progressProperty string Custom property the runtime writes progress into.

Range math and fidelity notes

Everything below is implemented in packages/scroll-anim-fallback-runtime/src/range-math.js as pure functions of numbers, with no DOM access, which is what makes it testable in Node and reusable outside this project.

Four measurements define a view timeline along one axis, all in the scroll container's scroll coordinate space:

Symbol Meaning
p The container's current scroll offset.
o Distance from the scroll origin to the subject's leading edge.
s The subject's extent along the axis.
v The scrollport's visible extent along the axis.

Each named range becomes a pair of scroll offsets:

Range Start End In words
cover o − v o + s Subject first touches the scrollport until it has fully left.
contain o − v + min(s, v) o + max(0, s − v) The smaller of subject and scrollport is fully inside the larger. Handles the tall-subject case, where "contain" means the subject covers the scrollport rather than fitting inside it.
entry o − v o − v + min(s, v) cover start to contain start.
exit o + max(0, s − v) o + s contain end to cover end.
entry-crossing o − v o − v + s The subject crossing the scrollport's end edge.
exit-crossing o o + s The subject crossing the scrollport's start edge.

A boundary written as entry 25% resolves to start + 0.25 × (end − start) of the entry range, and percentages outside 0–100 extrapolate, matching the spec. view-timeline-inset shrinks v on each side and shifts the offsets accordingly. Progress is then

progress = clamp((p − rangeStart) / (rangeEnd − rangeStart), 0, 1)

For scroll() timelines the range is simply [0, scrollHeight − clientHeight] along the axis. A zero-length range (unscrollable content, or a degenerate animation-range) reports 0 before the position and 1 at or after it, which is how a native timeline with no travel behaves.

Where fidelity is exact, and where it is close

Exact. Progress values for view() and scroll() timelines across all six named ranges and arbitrary percentage offsets. This is the part with real test coverage, and a fallback element in progress mode should be visually indistinguishable from the native version.

Close. observer mode is deliberately not a progress reproduction. It plays a fixed-duration transition once the element intersects instead of tracking the scroll. For a reveal that runs over entry this looks right and costs nothing; if you need frame-accurate tracking of an entry range, set fallbackMode: 'progress'.

Approximate. Timing functions declared per-keyframe via animation-timing-function are carried into the WAAPI keyframes, but an easing on the animation shorthand is not applied to the fallback, because progress mapping is linear by definition. Named timelines (timeline-scope and view-timeline-name referenced from an ancestor) are recorded in the manifest but resolved by the runtime as if the timeline were the element's own nearest scroller — correct in the common case, wrong when the name deliberately points somewhere else.


Examples

Three self-contained demos live in examples/, each pairing an HTML page with its source CSS under examples/src/:

Demo Source Timeline Fallback mode
Reveal on enter reveal-on-enter.css view() over entry 0%entry 100% observer
Parallax hero parallax-hero.css view() over cover, plus a mixed containcover range progress
Reading progress bar reading-progress.css scroll(root block) progress

Build them and open the index:

npm run examples
open examples/index.html   # or xdg-open / start

Each demo prints a badge showing which code path is live and links to its own forced-fallback variant. The parallax demo additionally displays the live value of --saf-progress.


Testing the fallback path

The hard part of shipping a fallback is proving it works, because your development browser probably has native support. Four ways, cheapest first:

1. The ?forceFallback=1 switch (built into the demos). Every demo reads the query string, loads a flattened stylesheet with the fallback rules at the top level instead of behind @supports not (...), and passes force: true to init(). Open examples/parallax-hero.html?forceFallback=1 in any browser and you are looking at the fallback. The flattened stylesheets are produced by examples/build.mjs; the same trick works in your own project by generating a second stylesheet with supportsWrap: false.

2. force: true on its own. Passing force: true to init() activates the runtime, but in a native browser the CSS inside @supports (animation-timeline: scroll()) is also still live, so both drivers run at once. Useful for confirming the runtime's progress values are sane (log --saf-progress and compare against the native animation), misleading as a visual check.

3. A browser without native support. Firefox shipped animation-timeline later than Chromium, and older Safari has no support at all; a browser or version below the support baseline is the only fully honest test. Current support data is summarised on browser support and progressive enhancement.

4. Disable the feature in Chromium. Launch with the feature switched off so @supports evaluates the way it does on an older browser:

chromium --disable-blink-features=ScrollTimeline --user-data-dir=/tmp/saf-test

Use a throwaway --user-data-dir so you do not disturb your normal profile. The flag name tracks Blink's internal feature name and can change between versions; verify with CSS.supports('animation-timeline: scroll()') in the console before trusting the result.

Also test with reduced motion enabled — Emulation → prefers-reduced-motion in DevTools — and confirm that content which animates from opacity: 0 still ends up visible.


Limitations

  • Static analysis only. Timelines applied through inline styles, CSSOM, or CSS-in-JS at runtime are invisible to the plugin. Bind those by hand with observe().
  • One animation per rule. Comma-separated animation lists are read for their first animation name. Split multi-animation rules if you need each one in the fallback.
  • Named timelines are simplified. timeline-scope and cross-element view-timeline-name references are recorded but resolved against the element's own nearest scroller.
  • Selector fidelity. The manifest stores selectors verbatim and the runtime runs querySelectorAll on them. A selector containing something querySelectorAll cannot evaluate (::before, an unsupported pseudo-class) will bind nothing. Pseudo-element animations cannot be reproduced by the fallback at all.
  • Not a polyfill. There is no ScrollTimeline object, document.timeline integration, or CSSOM support. If you need spec-complete behaviour rather than a graceful degradation, use a real polyfill; the trade-offs are laid out in how to polyfill scroll-timeline for Safari.
  • observer mode is one-way per intersection. The class is toggled off when the element leaves, so an element scrolled past and back re-plays the transition. Remove the disconnect by switching that timeline to progress mode if you need scroll-linked reversal.

Development and tests

The repository is an npm workspaces monorepo. postcss is the only devDependency, and tests use the built-in node --test runner — there is no test framework, bundler, or transpiler anywhere in the tree.

npm install
npm test            # builds the UMD bundle, then runs every test
npm run build       # just the runtime bundle
npm run examples    # build + demo stylesheets and manifests
Suite Covers
packages/postcss-scroll-anim-fallback/test/plugin.test.mjs @supports wrapping, property detection, idempotency across repeated passes, manifest extraction, keyframe derivation, every option
packages/postcss-scroll-anim-fallback/test/parse.test.mjs Value parsers: timelines, ranges, animation shorthand, keyframe selectors, mode selection
packages/scroll-anim-fallback-runtime/test/range-math.test.mjs Every named range, insets, percentage offsets, progress clamping, degenerate ranges
packages/scroll-anim-fallback-runtime/test/bundle.test.mjs The UMD build evaluated in a bare node:vm realm, as a browser <script> would

The runtime's DOM code lives in src/index.js and src/dom.js; the pure math lives in src/range-math.js precisely so the interesting logic is testable without a headless browser.

Repository layout

scroll-anim-fallback/
├── packages/
│   ├── postcss-scroll-anim-fallback/
│   │   ├── index.js            # plugin entry, @supports wrapping, manifest emission
│   │   ├── lib/detect.js       # which declarations are scroll-driven
│   │   ├── lib/parse.js        # timeline / range / shorthand value parsers
│   │   ├── lib/keyframes.js    # @keyframes collection and normalisation
│   │   ├── lib/fallback.js     # fallback CSS generation
│   │   ├── lib/manifest.js     # manifest entry construction and mode selection
│   │   └── test/
│   └── scroll-anim-fallback-runtime/
│       ├── src/index.js        # init/destroy/refresh/observe, drivers
│       ├── src/dom.js          # measurement and DOM helpers
│       ├── src/range-math.js   # pure range calculations
│       ├── build.mjs           # hand-written UMD bundler
│       └── test/
└── examples/

A complete worked example

1. Input CSS

@keyframes reveal-up {
  from { opacity: 0; transform: translateY(48px); }
  to   { opacity: 1; transform: translateY(0); }
}

.reveal {
  animation: reveal-up 1s linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

2. Output CSS

@keyframes reveal-up {
  from { opacity: 0; transform: translateY(48px); }
  to   { opacity: 1; transform: translateY(0); }
}

@supports (animation-timeline: scroll()) {
  .reveal {
    animation: reveal-up 1s linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}

@supports not (animation-timeline: scroll()) {
  .reveal {
    opacity: 0;
    transform: translateY(48px);
    transition: opacity var(--saf-duration, 600ms) var(--saf-easing, ease),
                transform var(--saf-duration, 600ms) var(--saf-easing, ease);
  }
  .reveal.saf-in-view {
    opacity: 1;
    transform: translateY(0);
  }
  @media (prefers-reduced-motion: reduce) {
    .reveal { transition: none; }
  }
}

3. Manifest JSON

{
  "version": 1,
  "classPrefix": "saf",
  "timelines": [
    {
      "id": "saf-0",
      "selector": ".reveal",
      "timeline": { "kind": "view", "name": null, "axis": "block", "scroller": "nearest" },
      "range": {
        "start": { "name": "entry", "percent": 0 },
        "end": { "name": "entry", "percent": 100 }
      },
      "animationName": "reveal-up",
      "keyframes": "reveal-up",
      "steps": [
        { "offset": 0, "declarations": { "opacity": "0", "transform": "translateY(48px)" } },
        { "offset": 1, "declarations": { "opacity": "1", "transform": "translateY(0)" } }
      ],
      "duration": 1000,
      "mode": "observer",
      "classes": { "inView": "saf-in-view", "progress": "saf-progress" },
      "progressProperty": "--saf-progress"
    }
  ]
}

4. Runtime wiring

<link rel="stylesheet" href="/css/app.css">

<article class="reveal"></article>

<script src="/js/scroll-anim-fallback.umd.js"></script>
<script>
  fetch('/scroll-anim-manifest.json')
    .then((r) => r.json())
    .then((manifest) => {
      const activated = ScrollAnimFallback.init({ manifest, reducedMotion: 'end' });
      // activated === false in a browser with native animation-timeline
    });
</script>

In Chrome the @supports block applies and the compositor runs the animation; init() returns false and the runtime does nothing else for the lifetime of the page. In a browser without support, the second @supports block applies, .reveal starts at opacity: 0, one IntersectionObserver per timeline adds .saf-in-view when the card arrives, and the CSS transition finishes the job.


Contributing

Bug reports, range-math corrections and new demos are all welcome. See CONTRIBUTING.md for the workflow and house style. Every change to the parsers or the range math should come with a test — they are cheap to write, since both are pure functions.

License

MIT © 2026 css-scroll-driven.

Further reading

Background reading that shaped the design of this toolkit:

Maintained by the team behind css-scroll-driven.com, a reference site for CSS scroll-driven animations.

About

PostCSS plugin + runtime that gives CSS scroll-driven animations a graceful IntersectionObserver fallback, gated behind @supports.

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors