The Web Animations API
The Web Animations API (WAAPI) gives you a JavaScript interface to the browser’s animation engine. Instead of relying on CSS keyframes alone or a third-party library like GSAP, you can create, control, and reverse animations directly from JS. The browser handles the heavy lifting — WAAPI animations run on the compositor thread where possible, which keeps them smooth even when the main thread is busy.
The animate() Shortcut
The quickest way to start an animation is calling animate() on any DOM element:
element.animate(keyframes, options);
This returns an Animation object immediately, so you have full control from the first frame.
const box = document.querySelector('.box');
const animation = box.animate([
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(200px)', opacity: 0.5 },
{ transform: 'translateX(400px)', opacity: 1 }
], {
duration: 800,
easing: 'ease-in-out',
fill: 'forwards'
});
After running this, box slides right 400px and stays there because of fill: 'forwards'.
Keyframe Formats
WAAPI accepts three keyframe formats:
Array of objects — each object is a keyframe at sequential offsets:
el.animate([
{ opacity: 0 }, // offset 0 (start)
{ opacity: 1 }, // offset 0.5 (middle)
{ opacity: 0 } // offset 1 (end)
], { duration: 500 });
Object notation — properties map to arrays of values, offsets are calculated automatically:
el.animate({
opacity: [0, 1, 0],
transform: ['scale(1)', 'scale(1.2)', 'scale(1)']
}, { duration: 600, iterations: 3 });
Explicit offsets — mix property values with offset and easing:
el.animate([
{ opacity: 0, offset: 0 },
{ opacity: 1, offset: 0.8, easing: 'ease-out' },
{ opacity: 0, offset: 1 }
], { duration: 1000 });
Offset values range from 0 to 1. Any keyframe without an explicit offset gets one assigned evenly between its neighbors.
Animation Options
The second argument to animate() is an options object:
| Option | Default | Description |
|---|---|---|
duration | 0 | Length in milliseconds |
delay | 0 | Start delay in milliseconds |
fill | 'none' | 'none', 'forwards', 'backwards', 'both' |
iterations | 1 | Number of repetitions (use Infinity for looping) |
direction | 'normal' | 'normal', 'reverse', 'alternate', 'alternate-reverse' |
easing | 'linear' | CSS easing function name |
iterationStart | 0 | Offset into the iteration timeline (e.g., 0.5 starts halfway) |
composite | 'replace' | 'add', 'replace', 'accumulate' |
// Looping, alternating animation
el.animate([
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
], {
duration: 2000,
iterations: Infinity,
direction: 'alternate',
easing: 'ease-in-out'
});
Controlling Playback with the Animation Object
animate() returns an Animation object with methods to control playback:
const animation = el.animate(keyframes, { duration: 1000 });
// Pause and resume
animation.pause();
animation.play();
// Jump around
animation.currentTime = 500; // seek to 500ms
animation.startTime = performance.now() - 300; // start 300ms ago
// Reverse
animation.reverse();
// Change speed
animation.updatePlaybackRate(2); // 2x speed
// Cancel completely
animation.cancel();
animation.playState tells you what’s happening: 'running', 'paused', 'finished', 'idle', or 'pending'.
You can listen for when animations finish:
animation.finished.then(() => {
console.log('Animation done');
});
// Or via event listener
animation.addEventListener('finish', () => {
console.log('Animation finished');
});
Filling: How the Animation Behaves Outside Its Active Time
The fill option controls what the element looks like before the animation starts and after it ends:
// Default — element reverts to pre-animation state
el.animate(keyframes, { duration: 500, fill: 'none' });
// Keeps final state after animation ends
el.animate(keyframes, { duration: 500, fill: 'forwards' });
// Shows first keyframe during the delay period
el.animate(keyframes, { duration: 500, delay: 200, fill: 'backwards' });
// Combines both — useful for responsive enter animations
el.animate(keyframes, { duration: 500, delay: 200, fill: 'both' });
Grouping and Scheduling with document.timeline
WAAPI provides a document.timeline that represents the default document time. This is useful for synchronizing multiple animations:
const flip = document.timeline.currentTime;
const anim1 = el1.animate(keyframes1, { duration: 300, delay: flip });
const anim2 = el2.animate(keyframes2, { duration: 300, delay: flip + 100 });
const anim3 = el3.animate(keyframes3, { duration: 300, delay: flip + 200 });
document.getAnimations() returns every active Animation object in the document — useful for pausing all animations at once:
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
document.getAnimations().forEach(anim => anim.pause());
} else {
document.getAnimations().forEach(anim => anim.play());
}
});
Staggered Animations
A common pattern is staggering — each element in a list animates slightly after the previous one:
const items = document.querySelectorAll('.item');
items.forEach((item, index) => {
item.animate([
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' }
], {
duration: 400,
delay: index * 80, // 80ms gap between each
fill: 'both',
easing: 'ease-out'
});
});
KeyframeEffect for Reusable Animation Definitions
If you want to apply the same animation to multiple elements or reuse it, create a KeyframeEffect separately and pass it to Animation:
const fadeSlide = new KeyframeEffect(
null, // target set later
[
{ opacity: 0, transform: 'translateY(10px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 300, fill: 'both', easing: 'ease-out' }
);
el1.animate(fadeSlide); // reuses the effect definition
el2.animate(fadeSlide);
el3.animate(fadeSlide);
The KeyframeEffect constructor signature is:
new KeyframeEffect(target, keyframes, options)
Pass null as target if you only want to define the effect without attaching it yet.
Performance Notes
WAAPI is hardware-accelerated for transform and opacity — these properties don’t trigger layout or paint. Keep your animations on these properties for the best performance:
// Good — runs on compositor
el.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(100px)' }
], { duration: 300 });
// Bad — triggers layout and paint, janky
el.animate([
{ width: '100px' },
{ width: '300px' }
], { duration: 300 });
Animations on other properties (background-color, width, height, etc.) are more expensive — they go through the main thread.
See Also
- javascript-css-typed-om — manipulate CSS properties programmatically with the typed object model that underlies WAAPI keyframes
- javascript-performance-api — measuring animation performance and frame rate in the browser
- javascript-service-workers — running animations in the background with service worker lifecycle awareness