Web Animations API Integration

Part of Core CSS Animation Fundamentals.

The Web Animations API is the imperative half of the same engine that runs CSS transitions and @keyframes. Calling element.animate() does not start a second, parallel animation system — it creates an animation object on exactly the timeline the cascade uses, with the same compositing rules, the same reduced-motion obligations and the same 16.7 ms frame budget. What it adds is a handle: an object you can pause, reverse, seek, cancel and await, and a set of keyframes you can compute at runtime instead of authoring ahead of time.

That distinction is what makes the API worth learning even on a site that prefers declarative motion. CSS is the better place to express what the motion looks like; the Web Animations API is the better place to express when it runs and what happens next. Reaching for it as a general-purpose animation library — recreating in JavaScript what a stylesheet already does well — reintroduces every problem the compositor path was designed to avoid.

What the API actually gives you

Three capabilities have no declarative equivalent, and they are the only reasons to reach for the API.

A completion signal that settles exactly once. animation.finished is a promise that resolves when the effect completes and rejects with an AbortError when it is cancelled. Compared with animationend, which fires per animation name, may fire several times for a shorthand, and never fires at all on cancellation, it is the only signal that can be safely awaited in a sequence. This is why coordinating sequential animations with promises is built on it rather than on events.

Runtime keyframe values. A drag-to-dismiss gesture has to animate from wherever the user let go, not from a value written in a stylesheet. A FLIP transition has to animate from a measured delta. Neither can be expressed as static CSS, and both are two lines of element.animate().

Playback control on a running effect. pause(), reverse(), updatePlaybackRate() and writes to currentTime operate on an animation that is already in flight, without restarting it. CSS has animation-play-state, and nothing else.

Everything outside those three — a hover lift, a page-load reveal, a looping spinner — belongs in the cascade, where it costs no JavaScript, applies before hydration, and is gated by the same media query as the rest of the design system.

Where each half of the engine belongsBoth produce compositor animations; they differ in who decides the values and who owns the lifecycle.Where each half of the engine belongsDeclarative (CSS)Values known at author timeApplies before any script runsGated by the cascade, including reducedmotionNo lifecycle to manageThe default for visual motionImperative (WAAPI)Values computed at runtimeNeeds script to have executedMust read the preference itselfReturns a handle you can awaitFor sequencing, gestures and measureddeltas
Both produce compositor animations; they differ in who decides the values and who owns the lifecycle.

Execution model: one timeline, two entry points

Every animation on a page — CSS or scripted — is an Animation object attached to a timeline. document.timeline is the default: a monotonic clock that starts at navigation and is shared by every time-driven effect on the document. A scroll-driven animation attaches to a ScrollTimeline instead, which is why scroll-driven animation patterns can be created imperatively with the same call.

When you call element.animate(keyframes, options), the browser builds a KeyframeEffect from the keyframe list, wraps it in an Animation, attaches it to document.timeline, and calls play(). From that point the pipeline is identical to a CSS animation: if every animated property is compositor-safe, the effect is handed to the compositor thread and sampled per vsync without waking the main thread. If any property triggers layout or paint, the effect stays on the main thread and costs style, layout and paint on every frame — exactly as the same property would in a stylesheet.

The consequence is worth stating plainly, because it is the most common misconception about the API: element.animate() is not a main-thread animation. Animating transform through the API is as cheap as animating it in CSS. Animating width through CSS is as expensive as animating it through the API. The mechanism does not decide the cost; the property list does, as covered in hardware-accelerated properties.

Where the two entry points genuinely differ is in ownership. A CSS animation is owned by the cascade: it exists while the rule matches and disappears when it stops matching. A scripted animation is owned by the object you hold; it survives class changes, and it keeps affecting the element until it finishes, is cancelled, or is replaced. That is a feature when you want a gesture to outlive a re-render, and a leak when nobody cancels it.

From a call to a composited frameThe scripted path joins the declarative one at the effect stage; everything after that is identical.From a call to a composited frameelement.animate()main threadKeyframeEffectbuiltone-offAnimation ontimelinedocument.timelineHanded tocompositorif compositor-safeSampled pervsyncGPUfinishedresolvesmain thread
The scripted path joins the declarative one at the effect stage; everything after that is identical.

API reference

API Accepted values Compositing tier Notes
element.animate(keyframes, options) Array or object keyframes; options or a duration Composite for transform/opacity Creates, attaches and plays in one call
animation.finished — (Promise) Resolves once; rejects with AbortError on cancel
animation.ready — (Promise) Resolves when the effect is handed off and playing
animation.playState idle | running | paused | finished The single source of truth for lifecycle checks
animation.currentTime number | null (ms) Writable; seeking does not restart the effect
animation.playbackRate number Negative values play backwards; 0 freezes
animation.updatePlaybackRate(r) number Smoothly retargets rate without a visual jump
animation.commitStyles() Style write Bakes the computed end state onto the element’s inline style
animation.persist() Stops the browser removing a filling animation
element.getAnimations() — (returns Animation[]) Read, no invalidation Includes CSS transitions and animations, not just scripted ones
animation.cancel() Removes the effect immediately and rejects finished
KeyframeEffect.composite replace | add | accumulate Composite The scripted form of animation composition

Annotated examples

A gesture that finishes what the user started

The canonical case for the API: the start value is not known until the pointer is released.

// Intent: fling a dismissed card off-screen from wherever the drag ended,
// at a duration proportional to the distance still to travel.
function flingAway(card, fromX, velocity) {
  const target = fromX > 0 ? window.innerWidth : -window.innerWidth;
  const distance = Math.abs(target - fromX);
  // Duration tracks distance so the perceived speed stays constant.
  const duration = Math.min(420, Math.max(160, distance / Math.max(0.6, velocity)));

  const animation = card.animate(
    [
      { transform: `translate3d(${fromX}px, 0, 0)`, opacity: 1 },
      { transform: `translate3d(${target}px, 0, 0)`, opacity: 0 },
    ],
    { duration, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' }
  );

  return animation.finished.then(() => card.remove());
}
/* Accessibility gate: the same interaction, without the travel. The script
   checks this preference before choosing the fling path at all. */
@media (prefers-reduced-motion: reduce) {
  .card { transition: opacity 120ms linear; }
}

Rendering Impact: transform + opacity — composite only. The duration arithmetic runs once on the main thread when the gesture ends; every frame after that is sampled on the compositor.

Awaiting a sequence instead of guessing at it

// Intent: run three stages back to back with no timer drift, and unwind
// cleanly if the component is torn down mid-sequence.
async function revealPanel(panel, rows) {
  const opts = { duration: 220, easing: 'cubic-bezier(0, 0, 0.2, 1)', fill: 'both' };
  try {
    await panel.animate(
      [{ opacity: 0, transform: 'scale(0.98)' }, { opacity: 1, transform: 'none' }],
      opts
    ).finished;

    // Stagger without setTimeout: each row's own delay is part of its effect.
    await Promise.all(
      rows.map((row, i) =>
        row.animate(
          [{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }],
          { ...opts, delay: i * 40 }
        ).finished
      )
    );
  } catch (error) {
    if (error.name !== 'AbortError') throw error; // a real failure, not a cancel
  }
}

Rendering Impact: composite only. Promise.all resolves on the main thread once, at the end; the staggered delays are held by the animation objects, so no timer can drift against them.

Cancelling the old effect before starting the new one

// Intent: never let two scripted effects fight over the same property.
function retarget(element, keyframes, options) {
  element.getAnimations()
    .filter((a) => a.transitionProperty !== 'opacity') // keep CSS fades alone
    .forEach((a) => a.cancel());
  return element.animate(keyframes, options);
}

Rendering Impact: cancel() is a main-thread bookkeeping call with no layout cost, but it discards the effect immediately — read playState first if the element must settle rather than snap.

DevTools workflow

Scripted animations appear in the same panels as declarative ones, with one difference worth knowing: they are listed by the element and effect rather than by an animation-name, because they do not have one.

  1. Open the Animations panel and press record before triggering the interaction. Scripted effects appear as unnamed groups; the element selector identifies them.
  2. Scrub the group’s timeline slider. Because the API exposes currentTime, the panel can seek a scripted animation exactly as it seeks a CSS one — a running gesture can be inspected frame by frame without re-performing it.
  3. Switch to Performance and confirm the effect appears under the Compositor track. A scripted animation on the Main track means a non-compositor property slipped into the keyframe list.
  4. In the console, $0.getAnimations() on the selected element lists everything currently affecting it, CSS and scripted alike. An element with more entries than you expect is the signature of a missing cancel().

Failure modes and fixes

The element snaps back when the animation ends. By default a fill: forwards animation is removed once it finishes if it is replaceable, and the element returns to its cascaded style. Either call commitStyles() before the animation is removed, or set the resting style through a class so the cascade already agrees with the end state. Persisting the animation with persist() works, but accumulating persisted animations on the same property is its own leak.

Two effects fight and the winner changes between frames. Scripted animations do not replace each other automatically unless they are replaceable and target the same properties. A hover effect and a gesture effect on the same transform produce whichever the engine resolves last. Cancel the previous effect, or set composite: 'add' deliberately if both should contribute.

finished never settles. An animation with iterations: Infinity has no end, so its promise stays pending forever. Awaiting it silently stalls a sequence. Use a finite iteration count for anything in a chain, and drive infinite loops from CSS where they cannot be awaited by accident.

The animation ignores the user’s motion preference. The cascade cannot reach a scripted effect: a global @media (prefers-reduced-motion: reduce) block does not shorten element.animate() durations. Every call site has to consult the preference itself, which is what the module in detecting prefers-reduced-motion in JavaScript exists to centralise.

Memory grows across a long session. Every animation object holds a reference to its target. Effects created per interaction and never cancelled accumulate, and so do their compositor layers. Cancel on teardown, and treat a rising getAnimations().length as the leak indicator.

The lifecycle every scripted effect needsFour obligations that CSS handles for you and script does not.The lifecycle every scripted effect needs1Check the motion preference before choosing the animated path.Reduce users get the state change with no travel2Cancel any in-flight effect on the same property first.getAnimations() length stays constant3Await finished rather than a timer, and treat AbortError as normal.No drift, no unhandled rejection on teardown4Commit or cascade the end state, then release the promotion.No snap-back and no leaked layer
Four obligations that CSS handles for you and script does not.

Accessibility and reduced-motion notes

Scripted motion carries the whole reduced-motion obligation itself. Three rules keep it honest.

Read the preference once, through a shared module, and pass the result into the animation decision rather than checking matchMedia at each call site. Subscribing to the media query’s change event matters more here than in CSS, because a running scripted effect will not stop on its own when the user flips the setting mid-session.

When the preference is set, prefer to skip the travel rather than shorten it. A 40 ms version of a 400 ms fling is still a fling; an instant position change with a short opacity fade communicates the same outcome without the optical flow. The classification in reduced-motion fallback patterns applies unchanged to scripted effects.

Finally, keep the end state identical on both paths. The reduced path must leave the DOM and the visual state exactly where the animated path would have left them, or the two branches drift apart and only one of them gets tested.

Frequently asked questions

Is element.animate() slower than a CSS animation?

No. Both create the same Animation object on the same timeline, and both are handed to the compositor when every animated property is compositor-safe. The cost difference is the one-off main-thread work of building the keyframe effect, which is negligible compared with a single layout.

Why does my element jump back to its original position when the animation ends?

A filling animation is removed once it finishes unless it is persisted, at which point the element falls back to its cascaded style. Either call commitStyles() to bake the end state onto the element, or set the resting style with a class so the cascade already matches where the animation left it.

Does the reduced-motion media query apply to scripted animations?

No. The cascade cannot reach an effect created in JavaScript, so a global reduced-motion reset does not shorten it. Each call site must read the preference itself, ideally through one shared module that also subscribes to change events so a mid-session toggle takes effect.

When should I use getAnimations() instead of tracking animations myself?

Always. getAnimations() returns every effect currently applying to the element, including CSS transitions and animations you did not create, and reading it does not force layout. A parallel array of animation references drifts out of date as soon as the cascade adds or removes an effect.

Can the Web Animations API drive scroll-linked motion?

Yes. Passing a ScrollTimeline as the timeline option attaches the effect to scroll progress instead of the document clock, giving the same compositor path as animation-timeline in CSS. Use it when the ranges or the source have to be computed at runtime; otherwise the declarative form is simpler.