MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Animations API: Beyond CSS Transitions

Use Web Animations API: keyframes, timing, playback control, and compositing.

Web AnimationsCSSJavaScriptFrontend

By MinhVo

Introduction

The Web Animations API (WAAPI) is a powerful browser-native JavaScript API that brings CSS animations and transitions under programmatic control. While CSS animations are great for simple effects, they quickly become limiting when you need precise playback control, dynamic keyframes, sequencing, or the ability to pause, reverse, and seek through animations. The Web Animations API bridges this gap by providing a unified model for animations that combines the performance of CSS with the flexibility of JavaScript.

Originally developed as a joint effort between Mozilla, Google, and the W3C, the Web Animations API has achieved broad browser support and is now considered baseline technology available in all modern browsers. It's the engine behind popular animation libraries like Motion (formerly Framer Motion) and GSAP, which use WAAPI under the hood for hardware-accelerated animations.

The WAAPI specification defines two core models that work together. The Timing Model controls when an animation starts, how long it lasts, how many times it repeats, and what easing curves it follows. The Animation Model controls what CSS properties change and how those changes are interpolated. This separation of concerns is what makes the API so powerful — you can independently manipulate timing (pause, reverse, speed up) without touching the animation definition itself.

Unlike CSS animations, which are declarative and defined entirely in stylesheets, WAAPI gives you a Animation object for every animation you create. This object exposes methods like play(), pause(), reverse(), finish(), and cancel(), plus promise-based finished and ready properties that make sequencing and coordination trivial. You can inspect the current playback state, read computed timing values, adjust playback rate on the fly, and even seek to arbitrary points in the animation timeline.

In this comprehensive guide, we'll explore every aspect of the Web Animations API, from basic keyframe animations to complex sequenced timelines. You'll learn how to create animations that are both performant and controllable, understand the compositing model that determines how animations layer together, and discover patterns for building reusable animation utilities that can power your entire application.

Web animations

Understanding the Web Animations API

Core Concepts

The Web Animations API revolves around two main concepts:

  1. Keyframe Effects: Define what properties change and how (the "what")
  2. Timing Controls: Define when and how the animation plays (the "when")
// Basic animation using Element.animate()
const element = document.querySelector('.box');
 
const animation = element.animate(
  // Keyframes (what changes)
  [
    { transform: 'translateX(0)', opacity: 1 },
    { transform: 'translateX(100px)', opacity: 0.5 },
    { transform: 'translateX(200px)', opacity: 1 },
  ],
  // Timing options (when/how it changes)
  {
    duration: 1000,
    iterations: Infinity,
    direction: 'alternate',
    easing: 'ease-in-out',
  }
);

Keyframe Formats

The Web Animations API supports multiple keyframe formats:

// Format 1: Array of keyframe objects
element.animate(
  [
    { backgroundColor: 'red', offset: 0 },
    { backgroundColor: 'blue', offset: 0.5 },
    { backgroundColor: 'green', offset: 1 },
  ],
  { duration: 2000 }
);
 
// Format 2: Object with arrays (property-specific keyframes)
element.animate(
  {
    backgroundColor: ['red', 'blue', 'green'],
    transform: ['scale(1)', 'scale(1.5)', 'scale(1)'],
  },
  { duration: 2000 }
);
 
// Format 3: Single keyframe (animate to values)
element.animate(
  { transform: 'rotate(360deg)' },
  { duration: 1000, fill: 'forwards' }
);
 
// Format 4: Property-value pairs with easing
element.animate(
  [
    { opacity: 0, easing: 'ease-out' },
    { opacity: 1, easing: 'ease-in' },
  ],
  { duration: 1000 }
);

Timing Options

interface EffectTiming {
  // Duration in milliseconds or CSS time string
  duration?: number | string; // 1000 or '1s'
  
  // Number of iterations (Infinity for infinite)
  iterations?: number;
  
  // Start delay in milliseconds
  delay?: number;
  
  // End delay in milliseconds
  endDelay?: number;
  
  // Direction of animation
  direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse';
  
  // How to fill before/after animation
  fill?: 'none' | 'forwards' | 'backwards' | 'both' | 'auto';
  
  // Easing function
  easing?: string; // 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'
                  // or CSS easing: 'cubic-bezier(0.1, 0.7, 1.0, 0.1)'
                  // or 'steps(5, jump-start)'
  
  // Iteration start (0-1)
  iterationStart?: number;
  
  // Playback rate
  playbackRate?: number;
}

Playback Control

Basic Playback Methods

const animation = element.animate(keyframes, options);
 
// Play/Pause
animation.play();
animation.pause();
 
// Cancel (removes animation effects)
animation.cancel();
 
// Finish (jump to end)
animation.finish();
 
// Reverse
animation.reverse();
 
// Commit current styles (make them permanent)
animation.commitStyles();
 
// Update playback rate
animation.playbackRate = 2; // 2x speed
animation.playbackRate = -1; // Reverse playback
animation.playbackRate = 0.5; // Half speed

Promise-Based Completion

// Wait for animation to finish
const animation = element.animate(keyframes, options);
 
await animation.finished;
console.log('Animation complete!');
 
// Or use the ready promise
await animation.ready;
console.log('Animation is playing!');
 
// Practical example: sequential animations
async function sequentialAnimation(elements: HTMLElement[]) {
  for (const element of elements) {
    const animation = element.animate(
      [{ opacity: 0 }, { opacity: 1 }],
      { duration: 300, fill: 'forwards' }
    );
    await animation.finished;
  }
}

State Monitoring

const animation = element.animate(keyframes, options);
 
// Check current state
console.log(animation.playState);
// 'idle' | 'running' | 'paused' | 'finished'
 
// Monitor state changes
animation.onfinish = (event) => {
  console.log('Animation finished');
};
 
animation.oncancel = (event) => {
  console.log('Animation cancelled');
};
 
// Get current time
console.log(animation.currentTime); // Current time in ms
console.log(animation.effect?.getComputedTiming());
// Returns:
// {
//   endTime: 1000,
//   activeDuration: 1000,
//   localTime: 500,
//   progress: 0.5,
//   currentIteration: 0,
// }

Animation timing

Advanced Keyframe Techniques

Easing Per-Keyframe

// Each keyframe can have its own easing
element.animate(
  [
    { 
      transform: 'translateY(0px)',
      easing: 'ease-out' // Easing FROM this keyframe to next
    },
    { 
      transform: 'translateY(-20px)',
      easing: 'ease-in' // Easing FROM this keyframe to next
    },
    { 
      transform: 'translateY(0px)',
    },
  ],
  { duration: 600 }
);
 
// Complex spring-like animation using per-keyframe easing
function springAnimation(element: HTMLElement, distance: number) {
  return element.animate(
    [
      { transform: `translateY(${distance}px)`, easing: 'ease-out', offset: 0 },
      { transform: 'translateY(-10px)', easing: 'ease-in-out', offset: 0.4 },
      { transform: 'translateY(5px)', easing: 'ease-in-out', offset: 0.6 },
      { transform: 'translateY(-2px)', easing: 'ease-in-out', offset: 0.8 },
      { transform: 'translateY(0px)', easing: 'ease-in', offset: 1 },
    ],
    { duration: 800, fill: 'forwards' }
  );
}

Composite Operations

// How animations combine with existing styles
element.animate(
  { transform: 'translateX(100px)' },
  {
    duration: 1000,
    composite: 'replace', // Default: replaces underlying value
  }
);
 
element.animate(
  { transform: 'rotate(45deg)' },
  {
    duration: 1000,
    composite: 'add', // Adds to underlying value
  }
);
 
element.animate(
  { transform: 'scale(1.2)' },
  {
    duration: 1000,
    composite: 'accumulate', // Accumulates with underlying value
  }
);

CSS Properties Animation

// Animate CSS custom properties
document.documentElement.animate(
  [
    { '--primary-hue': '0' },
    { '--primary-hue': '360' },
  ],
  { duration: 10000, iterations: Infinity }
);
 
// Animate individual transform properties
element.animate(
  { translate: '100px 0' },
  { duration: 1000, fill: 'forwards' }
);
 
element.animate(
  { rotate: '45deg' },
  { duration: 1000, fill: 'forwards' }
);
 
element.animate(
  { scale: '1.5' },
  { duration: 1000, fill: 'forwards' }
);
 
// These compose correctly without overriding each other!

The Animation Interface Deep Dive

Every call to element.animate() returns an Animation object that gives you full programmatic control. Understanding this interface is key to unlocking the API's power.

const animation = element.animate(
  [
    { transform: 'translateX(-100%)', opacity: 0 },
    { transform: 'translateX(0)', opacity: 1 },
  ],
  { duration: 500, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' }
);
 
// Playback state: 'idle' | 'running' | 'paused' | 'finished'
console.log(animation.playState);
 
// Current time in the animation (milliseconds)
console.log(animation.currentTime);
 
// Playback rate: 1 = normal, 2 = double speed, -1 = reverse
animation.playbackRate = 2;
 
// Get detailed computed timing information
const timing = animation.effect?.getComputedTiming();
// Returns: { activeDuration, currentTime, progress, currentIteration, ... }

Promise-Based Lifecycle

The ready and finished promises are one of WAAPI's biggest advantages over CSS animations. Instead of listening for string-based animationend events, you get proper promise chains:

const animation = element.animate(keyframes, options);
 
// 'ready' resolves when the animation begins playing
await animation.ready;
console.log('Animation is now running on the compositor');
 
// 'finished' resolves when the animation completes all iterations
await animation.finished;
console.log('All iterations complete');
element.classList.add('animation-done');

This makes sequential and parallel animation orchestration straightforward:

// Run animations one after another
async function sequence(animations: Animation[]) {
  for (const anim of animations) {
    anim.play();
    await anim.finished;
  }
}
 
// Run all animations simultaneously, then execute cleanup
async function parallel(animations: Animation[]) {
  animations.forEach(a => a.play());
  await Promise.all(animations.map(a => a.finished));
  console.log('All done');
}

Dynamic Playback Rate

Changing playbackRate at runtime creates powerful interactive effects without redefining keyframes:

// Scrub animation based on scroll progress
const scrubAnimation = element.animate(
  { transform: ['translateY(100px)', 'translateY(0)'] },
  { duration: 1000, fill: 'both' }
);
scrubAnimation.pause(); // Pause so we can control manually
 
window.addEventListener('scroll', () => {
  const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  scrubAnimation.currentTime = scrollPercent * scrubAnimation.effect?.getComputedTiming().activeDuration;
});
 
// Smooth speed changes (e.g., parallax-like effects)
function setAnimationSpeed(animation: Animation, targetRate: number) {
  animation.playbackRate = targetRate;
}

Animation Timelines

Document Timeline

By default, every animation is tied to the document timeline, which advances based on performance.now():

// Default timeline (document timeline)
const animation = element.animate(keyframes, {
  duration: 1000,
  timeline: document.timeline, // Default — can be omitted
});
 
// Access the current document timeline time
console.log(document.timeline.currentTime); // milliseconds since page load

ScrollTimeline

Scroll-linked animations are one of the most impactful use cases for WAAPI. Instead of manually listening to scroll events and calculating progress, ScrollTimeline binds animation progress directly to scroll position:

const scrollContainer = document.querySelector('.scroll-container');
 
const animation = element.animate(
  { opacity: [0, 1], transform: ['translateY(100px)', 'translateY(0)'] },
  {
    timeline: new ScrollTimeline({
      source: scrollContainer,
      axis: 'block', // 'block' for vertical, 'inline' for horizontal
    }),
    fill: 'both',
  }
);
// Progress is linked to scroll position:
// 0% scroll = 0% animation progress
// 100% scroll = 100% animation progress

A practical example — a reading progress indicator:

function createProgressBar() {
  const bar = document.createElement('div');
  bar.className = 'reading-progress';
  document.body.appendChild(bar);
 
  bar.animate(
    { scaleX: [0, 1] },
    {
      timeline: new ScrollTimeline({
        source: document.documentElement,
        axis: 'block',
      }),
      fill: 'both',
      easing: 'linear',
    }
  );
}

ViewTimeline

ViewTimeline extends scroll-driven animations to elements entering and exiting the viewport. This is ideal for scroll-triggered reveal animations without any JavaScript event listeners:

const cards = document.querySelectorAll('.card');
 
cards.forEach(card => {
  card.animate(
    {
      opacity: [0, 1, 1, 0],
      transform: ['scale(0.9)', 'scale(1)', 'scale(1)', 'scale(0.9)'],
    },
    {
      timeline: new ViewTimeline({
        subject: card,
        axis: 'block',
      }),
      rangeStart: 'entry 0%',
      rangeEnd: 'exit 100%',
    }
  );
});

This approach runs entirely on the compositor thread, making it far more performant than IntersectionObserver-based class toggling for scroll animations.

Building Animation Utilities

Animation Presets Library

// animations/presets.ts
interface AnimationPreset {
  keyframes: Keyframe[];
  options: KeyframeAnimationOptions;
}
 
const presets = {
  fadeIn: {
    keyframes: [{ opacity: 0 }, { opacity: 1 }],
    options: { duration: 300, fill: 'forwards', easing: 'ease-out' },
  },
  
  fadeOut: {
    keyframes: [{ opacity: 1 }, { opacity: 0 }],
    options: { duration: 300, fill: 'forwards', easing: 'ease-in' },
  },
  
  slideUp: {
    keyframes: [
      { transform: 'translateY(20px)', opacity: 0 },
      { transform: 'translateY(0)', opacity: 1 },
    ],
    options: { duration: 400, fill: 'forwards', easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
  },
  
  slideDown: {
    keyframes: [
      { transform: 'translateY(-20px)', opacity: 0 },
      { transform: 'translateY(0)', opacity: 1 },
    ],
    options: { duration: 400, fill: 'forwards', easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
  },
  
  scaleIn: {
    keyframes: [
      { transform: 'scale(0.9)', opacity: 0 },
      { transform: 'scale(1)', opacity: 1 },
    ],
    options: { duration: 300, fill: 'forwards', easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
  },
  
  shake: {
    keyframes: [
      { transform: 'translateX(0)' },
      { transform: 'translateX(-10px)' },
      { transform: 'translateX(10px)' },
      { transform: 'translateX(-10px)' },
      { transform: 'translateX(10px)' },
      { transform: 'translateX(0)' },
    ],
    options: { duration: 500, easing: 'ease-in-out' },
  },
  
  bounce: {
    keyframes: [
      { transform: 'translateY(0)', offset: 0 },
      { transform: 'translateY(-20px)', offset: 0.3 },
      { transform: 'translateY(0)', offset: 0.5 },
      { transform: 'translateY(-10px)', offset: 0.7 },
      { transform: 'translateY(0)', offset: 0.85 },
      { transform: 'translateY(-4px)', offset: 0.95 },
      { transform: 'translateY(0)', offset: 1 },
    ],
    options: { duration: 800, easing: 'ease-out' },
  },
};
 
function animate(element: HTMLElement, preset: keyof typeof presets): Animation {
  const { keyframes, options } = presets[preset];
  return element.animate(keyframes, options);
}
 
export { presets, animate };

Stagger Animation Utility

// Stagger animation for lists
function staggerAnimate(
  elements: HTMLElement[],
  keyframes: Keyframe[],
  options: KeyframeAnimationOptions & { stagger?: number } = {}
): Animation[] {
  const { stagger = 50, ...animationOptions } = options;
  
  return elements.map((element, index) => {
    return element.animate(keyframes, {
      ...animationOptions,
      delay: (animationOptions.delay || 0) + index * stagger,
    });
  });
}
 
// Usage
const items = document.querySelectorAll('.list-item') as NodeListOf<HTMLElement>;
staggerAnimate(Array.from(items), presets.slideUp.keyframes, {
  duration: 400,
  stagger: 80,
  fill: 'forwards',
  easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
});

Web Animations API

Performance Considerations

Compositor-Only Properties

// Good: Only animates compositor properties (no layout/paint)
element.animate(
  { transform: ['translateX(0)', 'translateX(100px)'] },
  { duration: 300, fill: 'forwards' }
);
 
element.animate(
  { opacity: [0, 1] },
  { duration: 300, fill: 'forwards' }
);
 
// Avoid: Triggers layout and paint
element.animate(
  { width: ['100px', '200px'] }, // Layout trigger
  { duration: 300 }
);
 
element.animate(
  { backgroundColor: ['red', 'blue'] }, // Paint trigger
  { duration: 300 }
);

Offscreen Animation Optimization

// Use will-change hint for animations
function prepareAnimation(element: HTMLElement) {
  element.style.willChange = 'transform, opacity';
  
  // Clean up after animation
  const animation = element.animate(keyframes, options);
  animation.finished.then(() => {
    element.style.willChange = 'auto';
  });
}

Comparison with CSS Animations

FeatureWeb Animations APICSS Animations
JavaScript controlFull playback controlLimited
Dynamic keyframesRuntime keyframe changesStatic @keyframes
SequencingPromise-based chainingAnimation events
PerformanceHardware-acceleratedHardware-accelerated
Timeline controlScrollTimeline, ViewTimelinescroll-timeline CSS
Browser supportAll modern browsersAll browsers
Use caseComplex, interactiveSimple, declarative

Real-World Animation Patterns

Modals need smooth enter/exit animations that don't conflict with other page animations. WAAPI makes this clean:

function animateModal(modal: HTMLElement, backdrop: HTMLElement): {
  open: () => Promise<void>;
  close: () => Promise<void>;
} {
  const openKeyframes = {
    backdrop: [{ opacity: 0 }, { opacity: 1 }],
    modal: [
      { opacity: 0, transform: 'translateY(20px) scale(0.95)' },
      { opacity: 1, transform: 'translateY(0) scale(1)' },
    ],
  };
 
  const closeKeyframes = {
    backdrop: [{ opacity: 1 }, { opacity: 0 }],
    modal: [
      { opacity: 1, transform: 'translateY(0) scale(1)' },
      { opacity: 0, transform: 'translateY(10px) scale(0.98)' },
    ],
  };
 
  const opts: KeyframeAnimationOptions = {
    duration: 250,
    easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
    fill: 'forwards',
  };
 
  return {
    async open() {
      backdrop.animate(openKeyframes.backdrop, opts);
      await modal.animate(openKeyframes.modal, opts).finished;
    },
    async close() {
      backdrop.animate(closeKeyframes.backdrop, opts);
      await modal.animate(closeKeyframes.modal, opts).finished;
    },
  };
}

Toast Notification System

Toast notifications benefit from promise-based sequencing for enter, hold, and exit phases:

function showToast(message: string, duration = 3000) {
  const toast = document.createElement('div');
  toast.className = 'toast';
  toast.textContent = message;
  document.body.appendChild(toast);
 
  const enterAnim = toast.animate(
    [
      { transform: 'translateY(100%)', opacity: 0 },
      { transform: 'translateY(0)', opacity: 1 },
    ],
    { duration: 300, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' }
  );
 
  enterAnim.finished.then(() => {
    // Hold for duration, then exit
    const exitAnim = toast.animate(
      [
        { transform: 'translateY(0)', opacity: 1 },
        { transform: 'translateY(100%)', opacity: 0 },
      ],
      { duration: 200, easing: 'ease-in', fill: 'forwards' }
    );
    exitAnim.finished.then(() => toast.remove());
  });
}

Page Transition Pattern

For single-page apps, WAAPI can orchestrate coordinated page transitions:

async function pageTransition(
  outgoing: HTMLElement,
  incoming: HTMLElement,
  direction: 'forward' | 'backward' = 'forward'
) {
  const offset = direction === 'forward' ? '-100%' : '100%';
 
  const exitAnimation = outgoing.animate(
    { transform: ['translateX(0)', `translateX(${offset})`] },
    { duration: 300, easing: 'ease-in-out', fill: 'forwards' }
  );
 
  incoming.animate(
    { transform: [`translateX(${-parseFloat(offset)}px)`, 'translateX(0)'] },
    { duration: 300, easing: 'ease-in-out', fill: 'forwards' }
  );
 
  await exitAnimation.finished;
  outgoing.style.display = 'none';
}

Code animation patterns

Accessibility: Respecting Reduced Motion

Animations can cause discomfort for users with vestibular disorders. Always check the prefers-reduced-motion media query:

function getAnimationDuration(normal: number): number {
  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  return prefersReduced ? 0 : normal;
}
 
function safeAnimate(
  element: HTMLElement,
  keyframes: Keyframe[],
  options: KeyframeAnimationOptions
): Animation {
  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
 
  if (prefersReduced) {
    // Jump to the final state instantly
    return element.animate(keyframes, { ...options, duration: 0 });
  }
 
  return element.animate(keyframes, options);
}
 
// Listen for preference changes
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
  const animations = document.getAnimations();
  animations.forEach(anim => {
    if (e.matches) {
      anim.playbackRate = 100; // Effectively instant
    } else {
      anim.playbackRate = 1; // Restore normal speed
    }
  });
});

Debugging WAAPI Animations

Listing Active Animations

The document.getAnimations() and element.getAnimations() methods are invaluable for debugging:

// See all animations currently running on the page
const allAnimations = document.getAnimations();
console.log(`Active animations: ${allAnimations.length}`);
 
allAnimations.forEach((anim, i) => {
  const timing = anim.effect?.getComputedTiming();
  console.log(`Animation ${i}:`, {
    playState: anim.playState,
    currentTime: anim.currentTime,
    progress: timing?.progress,
    duration: timing?.activeDuration,
  });
});
 
// See animations on a specific element
const btn = document.querySelector('button');
const btnAnimations = btn?.getAnimations();
console.log('Button animations:', btnAnimations?.length);

Chrome DevTools Animation Inspector

Chrome DevTools provides a dedicated Animations panel (under the Elements tab) that visualizes all WAAPI and CSS animations. You can pause, scrub, replay, and modify timing in real time. This is the single most useful tool for debugging animation issues.

// Mark an animation for easy identification in DevTools
const animation = element.animate(keyframes, options);
animation.id = 'modal-enter'; // Visible in DevTools animation inspector

Common Pitfalls and Solutions

PitfallImpactSolution
Not using fill: 'forwards'Element snaps back to original stateSet fill: 'forwards' for animations whose final state should persist
Animating layout properties (width, height, top, left)Triggers layout recalculation, janky 60fps targetUse transform and opacity — they run on the compositor thread
Not cleaning up finished animationsMemory leaks over timeCall animation.cancel() or let it be garbage collected
Not awaiting finishedRace conditions in sequencesAlways await animation.finished in async flows
Ignoring prefers-reduced-motionAccessibility violations, user discomfortCheck the media query and provide instant or no-animation alternatives
Creating too many concurrent animationsCompositor overload, dropped framesBatch animations and use requestAnimationFrame for coordination

Comparison with CSS Animations

FeatureWeb Animations APICSS Animations
JavaScript controlFull playback control (play, pause, reverse, seek)Limited (add/remove classes)
Dynamic keyframesRuntime keyframe changesStatic @keyframes rules
SequencingPromise-based finished and readyanimationend event listeners
PerformanceHardware-accelerated on compositorHardware-accelerated on compositor
Timeline controlScrollTimeline, ViewTimelinescroll-timeline CSS property
Browser supportAll modern browsers (Baseline 2023)All browsers including legacy
Use caseComplex, interactive, orchestratedSimple, declarative, standalone

The key takeaway: CSS animations are ideal for simple, state-driven transitions (hover effects, class toggles). WAAPI shines when you need programmatic control, sequencing, scroll-linking, or dynamic animation parameters.

Testing Strategies

// Test animation behavior
describe('Animation tests', () => {
  it('completes fade-in animation', async () => {
    const element = document.createElement('div');
    document.body.appendChild(element);
 
    const animation = element.animate(
      [{ opacity: 0 }, { opacity: 1 }],
      { duration: 100 }
    );
 
    await animation.finished;
 
    expect(animation.playState).toBe('finished');
    expect(getComputedStyle(element).opacity).toBe('1');
  });
 
  it('respects reduced motion preference', () => {
    // Mock prefers-reduced-motion
    window.matchMedia = jest.fn().mockImplementation(query => ({
      matches: query === '(prefers-reduced-motion: reduce)',
      media: query,
    }));
 
    const element = document.createElement('div');
    const animation = safeAnimate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 300 });
 
    // Should use reduced animation (duration 0)
    expect(animation.effect?.getComputedTiming().duration).toBe(0);
  });
 
  it('cancels animations on unmount', async () => {
    const element = document.createElement('div');
    const animation = element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 1000 });
 
    // Simulate component unmount
    animation.cancel();
 
    expect(animation.playState).toBe('idle');
    expect(element.getAnimations().length).toBe(0);
  });
});

Future Outlook

The Web Animations API continues to evolve with the web platform:

  1. Scroll-driven animations: ScrollTimeline and ViewTimeline are now in all major browsers, replacing the need for libraries like ScrollTrigger for many use cases.
  2. Individual transform properties: translate, rotate, and scale as separate CSS properties that compose correctly — no more transform shorthand conflicts.
  3. CSS custom property animation: Animating --custom-properties directly opens up theming and design token animations.
  4. Timeline scope improvements: More granular control over animation timing with named timelines and timeline scoping.

The Web Animations API provides programmatic control over animations with better performance than JavaScript-based animation libraries for many use cases. Understanding WAAPI means you can build sophisticated animation systems without any external dependencies.

Conclusion

The Web Animations API provides the best of both worlds — the performance of CSS animations with the flexibility of JavaScript. It's the foundation that modern animation libraries are built on, and understanding it gives you fine-grained control over every aspect of your animations. Whether you're building simple hover effects or complex sequenced experiences, the Web Animations API has you covered. Start with the basics of element.animate(), explore playback control and timelines, and gradually adopt more advanced patterns as your needs grow.