Animations with the Web Animations API animate() Method
element.animate() from the Web Animations API is the only standard way to start, pause, reverse, and await an animation from JavaScript at the same performance as CSS. Every modern browser supports it.
When to use it is easy to state. If you need to run something after an animation finishes, compute values at runtime, or pause and rewind, use WAAPI. Otherwise CSS is enough.
This article covers the syntax, awaiting completion with finished, the trap in fill and how commitStyles() fixes it, and a practical ripple effect.
The animate() syntax
Call element.animate(keyframes, options) and control the animation through the returned Animation object.
const box = document.querySelector('.box');
const animation = box.animate(
[
{ transform: 'translateX(0px)', opacity: 1 },
{ transform: 'translateX(100px)', opacity: 0.5 },
],
{
duration: 1000,
easing: 'ease-out',
iterations: 1,
}
);
| Option | Meaning | Common values |
|---|---|---|
duration |
Length in milliseconds | 300–600 |
easing |
Timing function | 'ease-out', 'cubic-bezier(...)' |
delay |
Delay before starting | — |
iterations |
Repeat count | Infinity to loop |
direction |
Playback direction | 'alternate' to ping-pong |
fill |
State after finishing | 'forwards' (see the caveat below) |
pseudoElement |
Target a pseudo-element | '::before' |
Two ways to write keyframes
Besides the array form above, you can pass an array per property, which is often easier to read.
box.animate(
{
transform: ['translateX(0px)', 'translateX(100px)'],
opacity: [1, 0.5],
offset: [0, 1], // position of each keyframe (0–1)
easing: ['ease-in'], // per-segment easing
},
{ duration: 1000 }
);
offset lets you place intermediate points explicitly—useful for “move fast for the first 30%, then ease back.”
Play, pause, reverse
The returned Animation object gives you control that CSS animations make awkward.
const animation = box.animate(
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
{ duration: 2000, iterations: Infinity }
);
document.getElementById('pause').onclick = () => animation.pause();
document.getElementById('play').onclick = () => animation.play();
document.getElementById('reverse').onclick = () => animation.reverse();
// change speed (0.5 = half, 2 = double)
document.getElementById('slow').onclick = () => { animation.playbackRate = 0.5; };
// jump to a point in time
animation.currentTime = 1000; // one second in
// discard entirely and restore the initial state
document.getElementById('reset').onclick = () => animation.cancel();
Note the difference: finish() jumps to the end state, cancel() returns to the pre-start state.
Awaiting completion: the finished property
This is the main reason to use WAAPI. animation.finished is a promise, so you can await the animation rather than guessing at durations with setTimeout.
async function fadeOutAndRemove(el) {
const animation = el.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 300, easing: 'ease-out' }
);
await animation.finished;
el.remove();
}
You can also wait on several at once:
const items = document.querySelectorAll('.item');
const animations = [...items].map((el, i) =>
el.animate(
[{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'none' }],
{ duration: 400, delay: i * 60, fill: 'backwards' }
)
);
await Promise.all(animations.map((a) => a.finished));
console.log('everything is visible');
Multiplying delay by the index gives you a staggered reveal for free. Doing the same in CSS meant writing one animation-delay per element.
One caveat: cancel() rejects finished. If cancellation is possible, always catch.
try {
await animation.finished;
el.remove();
} catch (err) {
// cancelled — do nothing
}
The fill: ‘forwards’ trap and commitStyles()
To keep the end state, the obvious move is fill: 'forwards'—but it has a cost. The animation object stays in memory and keeps overriding other CSS on that element.
On screens with many elements this accumulates. The correct pattern is to bake the result into inline styles with commitStyles() and then cancel().
const animation = box.animate(
[{ transform: 'none' }, { transform: 'translateX(100px)' }],
{ duration: 500, fill: 'forwards' }
);
await animation.finished;
animation.commitStyles(); // write the end state to the style attribute
animation.cancel(); // discard the animation
The element now carries style="transform: translateX(100px)" and the animation is gone, so ordinary CSS can override it again.
In practice: a ripple effect
A circle expanding from the click point, built with WAAPI. Three things matter:
- Animate
transform: scale(), notwidth/height—animating size forces layout and causes jank - Get the coordinate space right:
clientXis viewport-relative and does not match aposition: absoluteparent - Clean up with
finished, not a secondsetTimeoutduration to keep in sync
.ripple-host {
position: relative;
overflow: hidden;
}
.ripple {
position: absolute;
border-radius: 50%;
background: rgb(255 255 255 / .5);
pointer-events: none;
width: 100px;
height: 100px;
will-change: transform, opacity;
}
const SIZE = 100;
document.querySelectorAll('.ripple-host').forEach((host) => {
host.addEventListener('pointerdown', async (event) => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const rect = host.getBoundingClientRect();
const ripple = document.createElement('span');
ripple.className = 'ripple';
// convert to coordinates relative to the host
ripple.style.left = `${event.clientX - rect.left - SIZE / 2}px`;
ripple.style.top = `${event.clientY - rect.top - SIZE / 2}px`;
host.appendChild(ripple);
const animation = ripple.animate(
[
{ transform: 'scale(0)', opacity: 0.6 },
{ transform: 'scale(2.5)', opacity: 0 },
],
{ duration: 500, easing: 'cubic-bezier(.2,.7,.4,1)' }
);
try {
await animation.finished;
} finally {
ripple.remove();
}
});
});
Subtracting rect.left and rect.top is the coordinate conversion. Omit it and the circle appears in the wrong place once the page has been scrolled.
The listener is pointerdown rather than click—reacting the moment the finger lands feels noticeably faster.
Reading existing animations
getAnimations() returns every running animation, including ones defined in CSS.
// stop everything running on this element
el.getAnimations().forEach((a) => a.cancel());
// wait for the whole page, CSS animations included
await Promise.all(
document.getAnimations().map((a) => a.finished.catch(() => {}))
);
“Stop everything before navigating away” becomes a single line. The catch prevents a cancelled animation from failing the whole Promise.all.
WAAPI or CSS?
| Requirement | Use |
|---|---|
| Simple hover or state change | CSS transition |
| Decorative infinite loop | CSS @keyframes |
| Run something after it finishes | WAAPI (finished) |
| Values computed at runtime (click coordinates) | WAAPI |
| Pause, reverse, change speed | WAAPI |
| Reveal on scroll into view | Intersection Observer + CSS |
| Motion tracking scroll position | CSS animation-timeline |
Reveal-on-scroll is covered in scroll animations with the Intersection Observer API.
Accessibility
WAAPI is not affected by CSS @media (prefers-reduced-motion: reduce). You have to check it in JavaScript.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
function animateSafely(el, keyframes, options) {
// when reduced motion is requested, jump straight to the end state
const duration = reduceMotion.matches ? 0 : options.duration;
return el.animate(keyframes, { ...options, duration });
}
Setting duration: 0 removes the motion while still reaching the end state, so your control flow is unaffected. Skipping the animation entirely would stall anything waiting on finished.
Summary
element.animate(keyframes, options)returns anAnimationobjectanimation.finishedlets you await completion—the main reason to use WAAPI. Stop jugglingsetTimeoutdurationscancel()rejectsfinished. Always catch- Do not leave
fill: 'forwards'in place; usecommitStyles()thencancel() - Animate
transformandopacity.width/heightcause jank - Convert coordinates with
getBoundingClientRect() getAnimations()controls CSS-defined animations too- Check
prefers-reduced-motionin JavaScript; falling back toduration: 0is safest
Leave simple motion to CSS and switch to WAAPI the moment you need “and then do this next”. That rule keeps the code from getting complicated for no reason.