The Web Animations API: CSS vs JavaScript Animations
A practical guide to the Web Animations API (WAAPI): creating animations in JavaScript, controlling playback, composing effects, performance tips, and when to use CSS vs JS.
What you'll learn
- ✓How the Web Animations API works and its relationship to CSS animations
- ✓How to create, control, and compose animations in JavaScript
- ✓When to use CSS animations vs WAAPI vs requestAnimationFrame
- ✓How to build scroll-driven animations with the new API
- ✓Performance rules for 60fps animations
Prerequisites
- •CSS transitions and keyframes basics
- •JavaScript DOM manipulation
Three Ways to Animate on the Web
You have three animation systems in the browser:
- CSS Transitions and Animations: Declarative, GPU-accelerated, limited control
- Web Animations API (WAAPI): Imperative, GPU-accelerated, full playback control
- requestAnimationFrame (rAF): Manual frame-by-frame rendering, maximum flexibility, most effort
All three ultimately feed into the same browser compositing pipeline. The difference is how you author them and how much control you need.
CSS Animations: The Default Choice
Start with CSS. It is the simplest and handles most cases:
/* Transition: animate between two states */
.button {
background: #3b82f6;
transition: background 200ms ease, transform 200ms ease;
}
.button:hover {
background: #2563eb;
transform: scale(1.05);
}
/* Keyframe animation: multi-step or looping */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fade-in-up 400ms ease-out forwards;
}
/* Staggered animation with custom properties */
.card:nth-child(1) { animation-delay: 0ms; }
.card:nth-child(2) { animation-delay: 100ms; }
.card:nth-child(3) { animation-delay: 200ms; }
When CSS is enough: hover effects, entrance animations, loading spinners, simple state transitions.
When CSS falls short: dynamic values, playback control (pause, reverse, seek), coordination between multiple elements, animations driven by user input.
The Web Animations API
WAAPI gives you the same GPU-accelerated animations as CSS, but with full JavaScript control.
Basic Usage
// Animate an element
const element = document.querySelector('.card')!;
const animation = element.animate(
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{
duration: 400,
easing: 'ease-out',
fill: 'forwards',
},
);
// Wait for it to finish
await animation.finished;
console.log('Animation complete');
Keyframe Formats
// Array of keyframes (explicit timing)
element.animate(
[
{ transform: 'scale(1)', offset: 0 },
{ transform: 'scale(1.2)', offset: 0.5 },
{ transform: 'scale(1)', offset: 1 },
],
{ duration: 600 },
);
// Object format (property-indexed)
element.animate(
{
opacity: [0, 1],
transform: ['translateX(-100px)', 'translateX(0)'],
},
{ duration: 400 },
);
Animation Options
element.animate(keyframes, {
duration: 1000, // Milliseconds
delay: 200, // Delay before starting
endDelay: 100, // Delay after ending
easing: 'ease-in-out', // CSS easing or cubic-bezier
iterations: Infinity, // Number of repeats
direction: 'alternate', // normal, reverse, alternate, alternate-reverse
fill: 'forwards', // none, forwards, backwards, both
composite: 'accumulate', // replace, add, accumulate
});
Playback Control
This is where WAAPI shines over CSS:
const animation = element.animate(keyframes, options);
// Pause and resume
animation.pause();
animation.play();
// Reverse direction
animation.reverse();
// Jump to a specific point (0 to duration)
animation.currentTime = 500;
// Change playback speed
animation.playbackRate = 2; // 2x speed
animation.playbackRate = 0.5; // Half speed
animation.playbackRate = -1; // Reverse at normal speed
// Cancel the animation
animation.cancel();
// Event listeners
animation.addEventListener('finish', () => console.log('Done'));
animation.addEventListener('cancel', () => console.log('Cancelled'));
// Promise-based completion
await animation.finished;
Staggered Animations
function stagger(
elements: Element[],
keyframes: Keyframe[],
options: KeyframeAnimationOptions,
staggerMs: number,
): Animation[] {
return elements.map((el, i) =>
el.animate(keyframes, {
...options,
delay: (options.delay ?? 0) + i * staggerMs,
}),
);
}
// Usage
const cards = document.querySelectorAll('.card');
const animations = stagger(
Array.from(cards),
[
{ opacity: 0, transform: 'translateY(30px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 500, easing: 'ease-out', fill: 'forwards' },
80,
);
// Wait for all to finish
await Promise.all(animations.map((a) => a.finished));
Composing Animations
Multiple animations can run on the same element simultaneously:
const element = document.querySelector('.box')!;
// First animation: move right
const move = element.animate(
{ transform: ['translateX(0)', 'translateX(300px)'] },
{ duration: 2000, fill: 'forwards' },
);
// Second animation: pulse opacity
const pulse = element.animate(
{ opacity: [1, 0.5, 1] },
{ duration: 600, iterations: Infinity },
);
// Stop the pulse after the move finishes
await move.finished;
pulse.cancel();
The composite Property
When multiple animations target the same property, composite controls how they combine:
// Base animation
element.animate(
{ transform: 'translateX(100px)' },
{ duration: 1000, fill: 'forwards' },
);
// Additional animation that ADDS to the base
element.animate(
{ transform: 'translateY(50px)' },
{ duration: 1000, fill: 'forwards', composite: 'add' },
);
// Result: element moves diagonally (100px right + 50px down)
Scroll-Driven Animations
The scroll-driven animations API ties animation progress to scroll position:
/* CSS approach */
@keyframes fade-in {
from { opacity: 0; transform: translateY(50px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
// JavaScript approach with WAAPI
const element = document.querySelector('.reveal')!;
element.animate(
[
{ opacity: 0, transform: 'translateY(50px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{
fill: 'both',
timeline: new ViewTimeline({
subject: element,
axis: 'block',
}),
rangeStart: 'entry 0%',
rangeEnd: 'entry 100%',
},
);
Scroll Progress Indicator
const progressBar = document.querySelector('.scroll-progress')!;
progressBar.animate(
{ transform: ['scaleX(0)', 'scaleX(1)'] },
{
fill: 'forwards',
timeline: new ScrollTimeline({
source: document.documentElement,
axis: 'block',
}),
},
);
.scroll-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: #3b82f6;
transform-origin: left;
z-index: 1000;
}
requestAnimationFrame: Maximum Control
For animations that cannot be expressed as keyframes — physics simulations, canvas drawing, game loops:
function springAnimation(
element: HTMLElement,
target: { x: number; y: number },
) {
let x = 0, y = 0;
let vx = 0, vy = 0;
const stiffness = 0.08;
const damping = 0.85;
function tick() {
// Spring physics
const dx = target.x - x;
const dy = target.y - y;
vx = (vx + dx * stiffness) * damping;
vy = (vy + dy * stiffness) * damping;
x += vx;
y += vy;
element.style.transform = `translate(${x}px, ${y}px)`;
// Stop when close enough
if (Math.abs(vx) > 0.01 || Math.abs(vy) > 0.01) {
requestAnimationFrame(tick);
}
}
requestAnimationFrame(tick);
}
Performance Rules
Animate Only Composite Properties
The browser can animate these properties on the GPU without triggering layout or paint:
transform(translate, rotate, scale)opacityfilterclip-path(in some browsers)
// GOOD: GPU composited, 60fps
element.animate({ transform: ['translateX(0)', 'translateX(100px)'] }, 300);
// BAD: triggers layout on every frame
element.animate({ left: ['0px', '100px'] }, 300);
element.animate({ width: ['100px', '200px'] }, 300);
Use will-change Sparingly
/* Hint to the browser before animation starts */
.will-animate {
will-change: transform, opacity;
}
/* Remove after animation completes */
.animation-done {
will-change: auto;
}
Do not apply will-change to everything. Each element with will-change: transform gets its own GPU layer, consuming memory.
Prefer CSS for Simple Cases
/* Simple hover effect: CSS is better */
.button {
transition: transform 200ms ease;
}
.button:hover {
transform: scale(1.05);
}
/* This is worse as WAAPI because you need event listeners */
Respect Reduced Motion
function safeAnimate(
element: Element,
keyframes: Keyframe[],
options: KeyframeAnimationOptions,
): Animation {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
if (prefersReducedMotion) {
// Skip animation, jump to final state
return element.animate(keyframes, {
...options,
duration: 0,
delay: 0,
});
}
return element.animate(keyframes, options);
}
When to Use What
| Scenario | Best tool |
|---|---|
| Hover effects, simple transitions | CSS transitions |
| Entrance animations, loading spinners | CSS keyframes |
| Animations needing pause/resume/seek | WAAPI |
| Staggered or orchestrated animations | WAAPI |
| Scroll-driven animations | WAAPI + ScrollTimeline or CSS |
| Physics simulations, canvas games | requestAnimationFrame |
| Interactive drag animations | requestAnimationFrame or WAAPI |
| Animation libraries (GSAP, Motion) | They use WAAPI/rAF internally |
Summary
The Web Animations API bridges the gap between simple CSS animations and manual requestAnimationFrame loops. It provides GPU-accelerated keyframe animations with JavaScript control: play, pause, reverse, seek, and compose. Scroll-driven animations connect animation progress to scroll position without JavaScript polling. Stick to CSS for simple transitions, reach for WAAPI when you need control, and drop to requestAnimationFrame only for physics or canvas work. Always animate composite properties (transform, opacity) and respect the reduced-motion preference.
Related articles
- Web Advanced Browser DevTools Tips and Tricks
Go beyond console.log: Network throttling, Performance flame charts, Memory snapshots, CSS debugging, JavaScript profiling, and hidden DevTools features that save hours.
- Web Measuring and Fixing Core Web Vitals
Hands-on techniques for measuring LCP, INP, and CLS with real code: the web-vitals library, Performance Observer, Chrome UX Report, and step-by-step fixes for each metric.
- Web Web Workers and Multithreading in the Browser
Move heavy computation off the main thread: dedicated Workers, SharedWorker, service workers, Comlink for ergonomic worker APIs, and patterns for CPU-intensive tasks.
- Web SPA vs SSR vs SSG: Rendering Strategies Explained
Understand Single Page Apps, Server-Side Rendering, and Static Site Generation — how each works, performance tradeoffs, SEO impact, and when to use which.