Skip to content
Codeloom
Web

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.

·7 min read · By Codeloom
Intermediate 13 min read

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:

  1. CSS Transitions and Animations: Declarative, GPU-accelerated, limited control
  2. Web Animations API (WAAPI): Imperative, GPU-accelerated, full playback control
  3. 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)
  • opacity
  • filter
  • clip-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

ScenarioBest tool
Hover effects, simple transitionsCSS transitions
Entrance animations, loading spinnersCSS keyframes
Animations needing pause/resume/seekWAAPI
Staggered or orchestrated animationsWAAPI
Scroll-driven animationsWAAPI + ScrollTimeline or CSS
Physics simulations, canvas gamesrequestAnimationFrame
Interactive drag animationsrequestAnimationFrame 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.