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.
- Why
- How it works
- Install
- PostCSS plugin
- Runtime
- The manifest format
- Range math and fidelity notes
- Examples
- Testing the fallback path
- Limitations
- Development and tests
- Contributing
- License
- Further reading
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.
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"]
Three ideas carry the whole design:
- The native rule is never rewritten. It is moved, verbatim, inside a feature query. Browsers with support see exactly the CSS you authored.
- The fallback is derived, not written. The plugin reads the
@keyframesthe animation refers to and synthesises the start state, the end state, and the transition between them. There is no second source of truth. - 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 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.
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 stylesheetsPoint a dependency at the local checkout:
cd ~/my-project
npm install ../scroll-anim-fallback/packages/postcss-scroll-anim-fallbackor 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.
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; exposeswindow.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.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.
| 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. |
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.
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.
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; }<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 });
}| 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 math — resolveNamedRange,
resolveRange, progressAt, viewProgress and friends — so you can reuse the
calculations directly.
| 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.
- Native browsers: one
CSS.supports()call. No listeners, no observers, no allocations, no DOM queries. observertimelines: oneIntersectionObserverper timeline. Nothing runs during scroll.progresstimelines: one passivescrolllistener per scroll container (not per element) and at most onerequestAnimationFramecallback per frame for all bindings combined. Geometry is measured lazily, cached, and invalidated by aResizeObserverrather 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 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 */ ]
}| 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. |
| 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. |
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.
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.
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 contain → cover 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 / startEach 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.
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-testUse 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.
- 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
animationlists are read for their first animation name. Split multi-animation rules if you need each one in the fallback. - Named timelines are simplified.
timeline-scopeand cross-elementview-timeline-namereferences are recorded but resolved against the element's own nearest scroller. - Selector fidelity. The manifest stores selectors verbatim and the runtime
runs
querySelectorAllon them. A selector containing somethingquerySelectorAllcannot 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
ScrollTimelineobject,document.timelineintegration, 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 polyfillscroll-timelinefor Safari. observermode 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 toprogressmode if you need scroll-linked reversal.
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.
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/
@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%;
}@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; }
}
}{
"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"
}
]
}<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.
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.
MIT © 2026 css-scroll-driven.
Background reading that shaped the design of this toolkit:
- Fallback strategies for legacy browsers — the wider menu of options when
animation-timelineis missing, and how to choose between them. - IntersectionObserver fallback for scroll animations — the pattern behind this project's
observermode. @supportsguard recipes foranimation-timeline— the feature-query forms the plugin emits and recognises.- Browser support and progressive enhancement — where support currently stands and how to decide your baseline.
- How to polyfill scroll-timeline for Safari — when a real polyfill beats a derived fallback.
- CSS scroll-driven animations vs IntersectionObserver — why the two are not interchangeable, and what you give up in the fallback.
- Respecting prefers-reduced-motion in CSS — the reasoning behind the runtime's
reducedMotion: 'end'default.
Maintained by the team behind css-scroll-driven.com, a reference site for CSS scroll-driven animations.