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.
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.
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.allresolves 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 — readplayStatefirst 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.
- Open the Animations panel and press record before triggering the interaction. Scripted effects appear as unnamed groups; the element selector identifies them.
- 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. - 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.
- 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 missingcancel().
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.
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.
Related
- Animation State Management — the state machine a scripted effect plugs into, and where
getAnimations()fits - Coordinating Sequential Animations with Promises — the sequencing pattern built on
finished - Hardware-Accelerated Properties — the property list that decides whether a scripted effect composites
- CSS Animation Composition & Layering — the declarative form of the
compositeoption - Detecting prefers-reduced-motion in JavaScript — the preference module every scripted effect should call
- element.animate() vs CSS Keyframes — a decision matrix for choosing the mechanism before you write either one
- commitStyles() and Persisting Animation End State — why a filling effect snaps back, and the three ways to keep its end state
- Controlling Playback Rate and Reverse with WAAPI — retargeting a running effect instead of restarting it