CSS animation-timeline vs WAAPI ScrollTimeline: Which API When

The scroll-driven animations specification ships with two front doors: the declarative animation-timeline property in CSS, and the imperative ScrollTimeline / ViewTimeline constructors passed to element.animate() in the Web Animations API. Both produce the same underlying scroll-linked animation object, and both are eligible for off-main-thread execution — so the choice is not about performance, it is about who owns the animation’s lifecycle: the stylesheet or your JavaScript. This page maps that decision, building on the Scroll-Timeline API reference and the compositor model from Core Animation Fundamentals & Browser Mechanics.

When to use each API

Default to CSS animation-timeline when:

  • The effect is static in shape — the keyframes, range, and target are known at authoring time and don’t depend on runtime data.
  • You want cascade integration for free: @supports guards, @media (prefers-reduced-motion: reduce) overrides, container queries, and theming all compose declaratively — no subscription code.
  • The page is server-rendered or hydration-sensitive. A stylesheet rule animates before any bundle loads; there is no flash of unanimated content and nothing to re-attach after hydration.
  • The rule fans out across many elements via a selector. One .card rule is fifty animations; the WAAPI equivalent is a loop that must also handle elements added later.

Reach for WAAPI ScrollTimeline / ViewTimeline when:

  • Ranges or keyframe values are computed at runtime — from element measurements, fetched data, user settings, or physics. WAAPI lets you set and later update rangeStart / rangeEnd on a live animation.
  • You need lifecycle control: cancel(), pause(), reverse(), swapping the timeline or effect, or awaiting animation.finished to sequence follow-up work.
  • Animations are created and destroyed with components in an SPA — an Animation object held in a variable is explicit to clean up, whereas a CSS rule’s lifetime is tied to matching selectors.
  • You are composing multiple effects on one element with different composite modes (composite: 'add'/'accumulate'), which is awkward to coordinate through comma-separated CSS animation lists.
Requirement CSS animation-timeline WAAPI constructors
Fixed reveal/parallax on many elements Best fit — one selector Loop + manual bookkeeping
Range computed from runtime measurements var() indirection, restyle to change Best fit — set/update options object
Cancellation, sequencing on finished Restyle-based; indirect Best fit — first-class methods
Reduced-motion and @supports handling Best fit — pure cascade Manual matchMedia / feature checks
Works before JS loads / after hydration Best fit — stylesheet-owned Needs mount hooks
Composite modes, effect swapping Limited Best fit
Off-main-thread interpolation Yes (compositor-safe properties) Yes (same engine path)

If your real question is CSS-vs-a-JS-animation-library rather than CSS-vs-the-platform-API, that broader trade-off is covered in the rendering pipeline guide.

CSS animation-timeline and WAAPI ScrollTimeline converge on the compositor Two horizontal lanes. The top lane, labelled CSS declarative, flows from a stylesheet rule using animation-timeline, through style resolution, to an engine-created scroll-linked animation. The bottom lane, labelled WAAPI imperative, flows from a new ScrollTimeline constructor, through element.animate, to a scriptable Animation object. Arrows from both lanes converge on a single box on the right labelled compositor thread, same off-main-thread interpolation for both. CSS — DECLARATIVE WAAPI — IMPERATIVE Stylesheet rule animation-timeline Style resolution Scroll-linked animation (engine-created) new ScrollTimeline() / new ViewTimeline() element.animate() { timeline, range } Animation object (scriptable) Compositor thread same interpolation

Capability differences that actually matter

Dynamic ranges. CSS animation-range values are fixed at style-resolution time; you can route them through custom properties, but changing a custom property forces a restyle and rebinds the animation. A WAAPI animation exposes rangeStart and rangeEnd as live, writable options — you can recompute them on ResizeObserver ticks without tearing the animation down.

Runtime composition. element.animate() returns an Animation whose effect, timeline, and composite mode are all swappable. Stacking an additive wobble on top of a scroll-linked translate is one extra animate() call with composite: 'add'. In CSS, multiple comma-separated animations can coexist but their interaction is limited to cascade order.

Cancellation and sequencing. animation.cancel() releases the effect and its compositor resources immediately; animation.finished is a promise you can await to chain work when progress reaches the end of the range (note that with a scroll timeline the user can scrub back, returning the animation to a running state — treat finished as “reached the end”, not “done forever”). The CSS equivalents — toggling classes so a selector stops matching — work, but the intent lives far from the effect.

Introspection. Both APIs meet in document.getAnimations(): CSS-declared scroll animations surface as CSSAnimation objects whose timeline is the same ScrollTimeline/ViewTimeline interface, with currentTime reported as a percentage CSSUnitValue. You can read a CSS-owned animation from JS freely; writing to it is fragile, because the next style recalculation can reassert the stylesheet’s values.

What CSS keeps exclusively. The cascade. A prefers-reduced-motion override, an @supports guard, and a container-query variant are three declarations away, need no event subscriptions, and cannot leak. Every one of those has to be re-implemented with matchMedia listeners in the WAAPI version — the accessibility half of that work alone is a page of code.

Implementation: the same effect in both APIs

The effect: a figure that scales from 0.8 to 1 and fades in as it enters the viewport, finishing by the time it covers 40% of the scroll port.

Step 1 — The CSS version

@keyframes figure-enter {
  from { opacity: 0; transform: scale(0.8); }
  to   { opacity: 1; transform: scale(1); }
}

.figure {
  animation: figure-enter linear both;
  animation-timeline: view(block);
  animation-range: entry 0% cover 40%;
}

/* Cascade-native accessibility override — no JS required */
@media (prefers-reduced-motion: reduce) {
  .figure {
    animation: none;
    animation-timeline: auto;
  }
}

Twelve lines, zero bytes of JavaScript, active before any bundle executes.

Step 2 — The WAAPI version

const figure = document.querySelector('.figure');

// Subject-based timeline: the JS twin of animation-timeline: view(block)
const timeline = new ViewTimeline({
  subject: figure,
  axis: 'block',
});

const animation = figure.animate(
  [
    { opacity: 0, transform: 'scale(0.8)' },
    { opacity: 1, transform: 'scale(1)' },
  ],
  {
    fill: 'both',
    timeline,                  // replaces the document's default time-based timeline
    rangeStart: 'entry 0%',    // the JS twin of animation-range
    rangeEnd: 'cover 40%',
    // No duration: progress spans the range. A millisecond duration would be
    // reinterpreted as a proportion of the range — omit it for scroll timelines.
  }
);

For a container-progress effect you would swap in new ScrollTimeline({ source: document.documentElement, axis: 'block' }) — the same scroller-vs-subject decision the CSS functions make, and the two constructors mirror scroll() and view() argument for argument.

Step 3 — Exercise the runtime advantages

Everything below is trivial in WAAPI and requires restyle gymnastics in CSS:

// Recompute the range when layout changes materially
const ro = new ResizeObserver(() => {
  animation.rangeEnd = figure.clientHeight > 600 ? 'cover 60%' : 'cover 40%';
});
ro.observe(figure);

// Sequence follow-up work on completion (re-entrant under scroll scrubbing)
animation.finished.then(() => figure.classList.add('has-entered'));

// Component teardown: release compositor resources deterministically
function destroy() {
  ro.disconnect();
  animation.cancel(); // also detaches the timeline reference
}

Step 4 — Wire up progressive enhancement

Feature-detect each API on its own terms — a browser could conceivably ship one before the other, and the scroll-timeline polyfill patches both surfaces (it parses animation-timeline rules and installs ScrollTimeline/ViewTimeline constructors, driving both from a main-thread scroll listener):

/* CSS path: static fallback outside the guard */
.figure { opacity: 1; transform: none; }

@supports (animation-timeline: view()) {
  .figure {
    animation: figure-enter linear both;
    animation-timeline: view(block);
    animation-range: entry 0% cover 40%;
  }
}
// JS path: constructor detection, with a threshold-based fallback
if (typeof ViewTimeline === 'function') {
  attachScrollLinkedAnimation(figure);
} else {
  // e.g. an IntersectionObserver class toggle for non-supporting engines
  attachThresholdFallback(figure);
}

The fallback branch is exactly the pattern dissected in CSS scroll-driven animations vs IntersectionObserver. One caution: under the polyfill, interpolation runs on the main thread, so the “performance parity” below holds only for native support.

Performance parity — and where it breaks

Natively, both APIs feed the same machinery: once the animation’s target properties are compositor-safe (transform, opacity), the browser ships the keyframes and the scroll-to-progress mapping to the compositor, and JavaScript is out of the loop for every subsequent frame. There is no sustained per-frame cost difference between a stylesheet-declared and an animate()-created scroll-linked animation.

The differences are at the edges:

  • Startup: the CSS version participates in the initial style pass; the WAAPI version waits for script evaluation and DOM queries — measurable on slow devices as a late-attaching animation.
  • Churn: creating and cancelling many WAAPI animations (e.g. on every route change) does allocation and compositor-registration work that a persistent CSS rule never repeats.
  • Foot-guns: reading animation.timeline.currentTime inside a scroll handler pulls you back into main-thread territory. Keep reads out of hot paths, per the frame-budget analysis in the rendering pipeline guide.
  • Named-timeline reach: when a timeline must be shared across component boundaries, CSS needs timeline-scope hoisting — see named timelines and timeline-scope — while WAAPI simply passes the timeline object around. Same capability, different ergonomics; identical compositor result.

Verification

  1. Confirm both produce the same timeline type. In the console: document.getAnimations().map(a => [a.constructor.name, a.timeline.constructor.name]) — expect ["CSSAnimation", "ViewTimeline"] for the stylesheet path and ["Animation", "ViewTimeline"] for the animate() path.
  2. DevTools → Animations drawer. Both variants appear with a scroll-position scrubber. If the WAAPI animation is missing, its timeline option was likely dropped (a TypeError at construction falls back silently in some wrapper code).
  3. Performance panel. Record a scroll pass over each variant: neither should emit per-frame Recalculate Style, Layout, or Paint events. If the WAAPI variant does, check that keyframes use transform/opacity only and that no code polls currentTime per frame.
  4. Range parity check. Scrub to the position where the figure’s leading edge meets the viewport edge: both variants must sit at progress 0; at 40% cover, both at progress 1. Divergence usually means the CSS animation-range and the JS rangeStart/rangeEnd strings drifted apart during refactoring.

Edge cases and gotchas

The animation shorthand resets animation-timeline. Writing animation: figure-enter linear both; after animation-timeline silently reverts the timeline to auto (document time). Declare the shorthand first, longhands after — the number-one cause of “my CSS version plays instantly on load”.

Millisecond durations mean something different. With a scroll timeline attached, duration: 300 does not mean 300ms; time-based values are mapped onto scroll progress proportionally (they matter only when sequencing multiple effects with delays inside one range). Omit duration and let it default to the full range — in both APIs.

Detached-node leaks in SPAs. A WAAPI animation holds references to its subject/source elements. Removing the component’s DOM without calling animation.cancel() can keep nodes reachable and compositor layers alive. CSS-owned animations die with their selectors — one reason to prefer the declarative path for high-churn UI.

Don’t mutate CSS-owned animations from JS. Setting rangeStart on a CSSAnimation may appear to work until the next restyle reasserts the stylesheet. If you need runtime mutation, own the animation end-to-end in WAAPI; if you need cascade behavior, own it end-to-end in CSS. Hybrid ownership is where the flakiest bugs live.

persist() and commitStyles() are WAAPI-only escape hatches. To freeze the end state into inline styles after a range completes — for instance before cancelling the animation — call animation.commitStyles() while the element has fill: 'both'. There is no CSS equivalent beyond animation-fill-mode.

Browser-specific notes

Chrome / Edge 115+. animation-timeline, animation-range, and the ScrollTimeline/ViewTimeline constructors (including rangeStart/rangeEnd in the animate() options bag) shipped together in Chromium 115. DevTools’ Animations drawer understands both variants.

Firefox. Both the CSS properties and the constructors sit behind the layout.css.scroll-driven-animations.enabled pref (Nightly). Feature-detect the constructor with typeof ViewTimeline === 'function' rather than 'ViewTimeline' in window inside sandboxed contexts, and keep the fallback branch tested.

Safari. Scroll-driven animation support is in active development; Technology Preview behavior should not be treated as shipping behavior for either API surface. Production Safari today takes the @supports-excluded static path or the polyfill path — and remember the polyfill’s interpolation is main-thread, so budget it like a scroll listener, not like a compositor animation.

Up: Understanding the CSS Scroll-Timeline API