The Web Animations API
CSS animations are wonderful right up until you need to decide something. Animate a card to the exact pixel where a user dropped it. Play three steps in order, but only if a fetch succeeds. Slow a spinner down when the network is congested. Let someone drag a scrubber that seeks the animation to any point. The moment the values or the timing come from your program instead of a stylesheet, @keyframes starts to feel like the wrong tool — you end up writing CSS custom properties and toggling classes and listening for animationend, and it’s brittle.
The Web Animations API (WAAPI) is the imperative counterpart. One method, element.animate(), starts an animation and hands you back a live Animation object — a handle you can play, pause, reverse, cancel, finish, seek, and speed up or slow down, and whose completion you can await like any promise. Same compositor-driven engine that runs CSS animations under the hood, but you’re holding the controls.
It’s a mature, ratified W3C standard. element.animate() and the core Animation interface are Baseline: widely available — every major engine has shipped them since around March 2020, so you can reach for them in production without a polyfill. (Scroll-driven timelines are the newer, less-settled part; we’ll get to their caveats at the end.)
The one call that starts it all
Everything begins with animate:
const anim = element.animate(keyframes, options);
Two arguments. The first describes what changes. The second describes how long and how — either a plain number of milliseconds, or an options object. The return value is an Animation, and by default it starts playing immediately.
Here’s a real fade-and-rise, the kind you’d run when an element enters the page:
const card = document.querySelector('.card');
const anim = card.animate(
[
{ opacity: 0, transform: 'translateY(12px)' }, // from
{ opacity: 1, transform: 'translateY(0)' }, // to
],
{
duration: 300,
easing: 'ease-out',
fill: 'both',
}
);
No stylesheet, no class toggling. The array is your keyframe list; the object is the timing. That’s the whole surface area of the simple case. Hit the button below to run exactly that call and watch the card fade and rise:
Keyframes: two shapes for the same idea
A keyframe is a snapshot of style at a point in the timeline. You can express the list two ways, and they compile to exactly the same thing.
Array of objects — one object per stop. This reads like a storyboard:
el.animate(
[
{ transform: 'scale(1)', offset: 0 },
{ transform: 'scale(1.4)', offset: 0.7 },
{ transform: 'scale(1)', offset: 1 },
],
600
);
Object of arrays — one entry per property, each holding the values it moves through. Compact when only a couple of properties change:
el.animate(
{
transform: ['scale(1)', 'scale(1.4)', 'scale(1)'],
offset: [0, 0.7, 1],
},
600
);
Property names are camelCase DOM style names, not CSS hyphen names: backgroundColor, not background-color. (Two odd ones: float becomes cssFloat, and the keyframe offset key shadows the CSS offset shorthand, so that one is cssOffset.)
Three special keys can appear on a keyframe and change how it’s placed or blended:
offset— a number from0to1fixing where this keyframe sits along the iteration. Omit it and offsets are spread evenly. Must be non-decreasing.easing— the timing curve applied from this keyframe to the next one, not the whole animation. This is the per-segment control CSS gives you withanimation-timing-functionon individual stops.composite— how this keyframe’s value combines with what’s already there (replace,add, oraccumulate). More on that below.
Timing options in one place
When you pass an object as the second argument, these are the fields worth knowing. They mirror CSS animation properties one-for-one.
el.animate(keyframes, {
duration: 400, // ms for one iteration (or a CSS time string)
iterations: 1, // how many times; Infinity to loop forever
delay: 100, // ms before it starts
endDelay: 0, // ms of dead time after the last iteration
easing: 'ease-in-out',// curve across the whole iteration
direction: 'normal', // normal | reverse | alternate | alternate-reverse
fill: 'none', // none | forwards | backwards | both
id: 'card-enter', // a name you can find it by later
});
Two of these bite people, so let’s be explicit.
Passing a bare number is shorthand for { duration: number }. So el.animate(frames, 250) and el.animate(frames, { duration: 250 }) are identical.
The Animation object is a remote control
This is the payoff. animate() returns an Animation, and it exposes a small, coherent control surface.
Transport methods:
anim.pause(); // freeze in place
anim.play(); // resume, or restart if finished
anim.reverse(); // flip direction, play toward the start
anim.finish(); // jump to the end (or start, if reversing)
anim.cancel(); // stop and remove all effects
Live properties you can read and write:
anim.currentTime = 150; // seek to 150ms into the timeline
anim.playbackRate = 2; // twice as fast; negative plays backward
anim.playState; // 'idle' | 'running' | 'paused' | 'finished'
anim.startTime; // when it was scheduled to begin
Because currentTime is writable, you get scrubbing for free. Wire it to a range input and you have a video-style scrubber over any animation:
scrubber.addEventListener('input', () => {
anim.pause();
anim.currentTime = scrubber.value; // 0 .. duration
});
And because playbackRate is writable, a “slow motion” toggle is one line — no re-authoring keyframes, no restart.
Here is the whole control surface wired to one looping animation. Play/pause it, flip its direction, drag the scrubber to seek currentTime by hand, and change the speed — all reaching into the same live playback:
.finished: sequencing without callback soup
Every Animation exposes a finished property — a promise that resolves with the animation once it completes. That single hook turns messy animationend listeners into ordinary async/await.
async function shake(el) {
const anim = el.animate(
[
{ transform: 'translateX(0)' },
{ transform: 'translateX(-6px)' },
{ transform: 'translateX(6px)' },
{ transform: 'translateX(0)' },
],
{ duration: 200, iterations: 2 }
);
await anim.finished; // resolves when the shake is done
}
await shake(input);
input.focus(); // runs only after the shake settles
Because it’s a real promise, you compose animations with the tools you already know. Run several in parallel and wait for all of them:
const all = elements.map(el =>
el.animate([{ opacity: 0 }, { opacity: 1 }], 300).finished
);
await Promise.all(all);
console.log('every item faded in');
There’s one sharp edge. Calling cancel() rejects the finished promise with an AbortError rather than resolving it. If you await it, guard against that, or an interrupted animation throws:
try {
await anim.finished;
} catch (err) {
if (err.name !== 'AbortError') throw err;
// cancelled — fine, just stop here
}
Composite modes: adding animations instead of clobbering
Normally a keyframe replaces whatever value the property had. That’s composite: 'replace', the default. But two animations on the same property normally fight — the newer one wins and the older is discarded.
Sometimes you want them to stack. A gentle idle bob plus a click-driven jump, both on transform, blending rather than one erasing the other. That’s what add and accumulate are for:
replace— this value overrides the underlying one. The default.add— the value is combined with the underlying one. Fortransformthat means the transforms are concatenated; for numbers they sum.accumulate— likeadd, but combines with the accumulated result of previous iterations rather than just laying alongside.
// A base drift that's always running…
el.animate(
{ transform: ['translateX(0)', 'translateX(10px)'] },
{ duration: 2000, iterations: Infinity, direction: 'alternate' }
);
// …plus a pop on click that ADDS on top, not replaces.
button.addEventListener('click', () => {
el.animate(
{ transform: ['scale(1)', 'scale(1.3)', 'scale(1)'] },
{ duration: 250, composite: 'add' }
);
});
With composite: 'add' the pop rides on top of the drift; the element keeps sliding and scales. Drop the composite and the pop’s transform would blow away the drift for its 250ms.
See the difference for yourself. The dot bobs forever on its own; click Pop to fire a scale on top. With the box unchecked it uses composite: 'add' and the bob continues underneath; tick the box to use replace and watch the pop snap the dot back to baseline while it runs:
Finding animations already running
The DOM tracks every active animation for you. Two entry points:
el.getAnimations(); // animations on this one element
document.getAnimations(); // every animation on the page
This includes animations you started via CSS @keyframes and CSS transitions — WAAPI and CSS share the same object model, so a CSS animation shows up here as an Animation (specifically a CSSAnimation) you can pause or inspect. Handy for a blanket “stop everything” or to respect a reduced-motion preference at runtime:
// Instantly settle every running animation on the page.
document.getAnimations().forEach(a => a.finish());
KeyframeEffect and Animation, the long way round
element.animate() is a convenience wrapper. Under it sit two constructors you can use directly when you want to build an effect without attaching it to an element yet, or reuse one effect across elements.
const effect = new KeyframeEffect(
cardEl, // target (or null to bind later)
[{ opacity: 0 }, { opacity: 1 }], // keyframes
{ duration: 300, easing: 'ease-out' } // timing
);
const anim = new Animation(effect, document.timeline);
anim.play();
el.animate(frames, opts) is essentially those three lines rolled into one, using the document’s default timeline. You rarely need the long form — but it’s the seam that makes the next feature possible: swapping in a different timeline.
Timelines: what “progress” even means
Every animation is driven by a timeline — the thing that answers “how far along are we?” The default is document.timeline, a monotonic clock in milliseconds since the page loaded. That’s why a 300ms animation takes 300ms: progress is measured against wall-clock time.
But progress doesn’t have to come from a clock. Swap the timeline and the same keyframes advance against a different signal. The most exciting example: scroll position.
Scroll-driven animations
A ScrollTimeline maps a scroll container’s position to animation progress. Scroll to the top, progress is 0; scroll to the bottom, progress is 1. A reading-progress bar becomes almost trivial:
const bar = document.querySelector('.progress');
bar.animate(
{ transform: ['scaleX(0)', 'scaleX(1)'] },
{
fill: 'both',
timeline: new ScrollTimeline({
source: document.documentElement, // the scroller
axis: 'block', // vertical
}),
}
);
No scroll listener, no requestAnimationFrame, no layout thrash. The browser ties the bar’s scaleX directly to scroll offset — and because it runs off the main thread, it stays smooth even while your JavaScript is busy.
Its sibling, ViewTimeline, measures progress by how far a specific element has travelled through the scrollport — perfect for “fade this card in as it enters view”:
card.animate(
{ opacity: [0, 1], transform: ['translateY(40px)', 'translateY(0)'] },
{
fill: 'both',
timeline: new ViewTimeline({ subject: card, axis: 'block' }),
}
);
Persisting the final state without leaks
Remember that fill: 'forwards' holds the last frame. Keep firing forward-filling animations and the browser accumulates them; it will eventually auto-remove replaced ones and fire a remove event, which can make styles jump back unexpectedly. The clean fix is commitStyles(): it writes the animation’s current computed values straight onto the element’s inline style, then you can safely cancel the animation.
const anim = el.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 300, fill: 'forwards' }
);
await anim.finished;
anim.commitStyles(); // bake opacity:0 into el.style
anim.cancel(); // drop the animation; the baked style remains
Now the final look lives in real inline CSS, no lingering fill animation, no remove-event surprise.
A worked example: a controllable toast
Pulling it together — a toast that slides in, waits, slides out, and whose dismissal you can trigger early, all through one Animation:
async function toast(message, ms = 3000) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = message;
document.body.append(el);
const slideIn = el.animate(
[
{ transform: 'translateY(100%)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 },
],
{ duration: 250, easing: 'ease-out', fill: 'forwards' }
);
await slideIn.finished;
// Let a click dismiss early by finishing the wait.
await new Promise(resolve => {
const timer = setTimeout(resolve, ms);
el.addEventListener('click', () => { clearTimeout(timer); resolve(); }, { once: true });
});
const slideOut = el.animate(
[{ opacity: 1 }, { opacity: 0, transform: 'translateY(100%)' }],
{ duration: 200, easing: 'ease-in', fill: 'forwards' }
);
await slideOut.finished;
el.remove();
}
Every phase is an awaited Animation. No animationend, no manual class bookkeeping, no timing drift between CSS and JS. That’s the shape of code WAAPI encourages: motion as a sequence of promises you can read top to bottom.
Here it is running. Show a toast, then either wait for it to time out or click it to dismiss early — the same await-based sequence handles both:
Summary
element.animate(keyframes, options)starts an animation from JavaScript and returns a liveAnimationobject. It’s Baseline: widely available (all major engines since ~2020).- Keyframes come as an array of objects or an object of arrays; per-keyframe
offset,easing, andcompositegive fine control. Property names are camelCase. optionsmirrors CSS timing:duration,iterations,delay,easing,direction,fill. A bare number means{ duration }. Watchfill— without it the element snaps back at the end.- The
Animationobject is a remote control:play,pause,reverse,finish,cancel, plus writablecurrentTime(scrubbing) andplaybackRate(speed/direction). .finishedis a promise —awaitit to sequence steps.cancel()rejects it withAbortError, so guard your awaits.composite: 'add'/'accumulate'stack animations instead of overriding;getAnimations()finds everything running, CSS animations included.commitStyles()bakes the final frame into inline styles so you can drop forward-fills cleanly.- Scroll-driven timelines (
ScrollTimeline/ViewTimeline) swap the clock for scroll position — powerful, but the JS constructors are not yet Baseline; feature-detect and prefer the CSS form for cross-browser support.