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 300600
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(), not width/height—animating size forces layout and causes jank
  • Get the coordinate space right: clientX is viewport-relative and does not match a position: absolute parent
  • Clean up with finished, not a second setTimeout duration 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 an Animation object
  • animation.finished lets you await completion—the main reason to use WAAPI. Stop juggling setTimeout durations
  • cancel() rejects finished. Always catch
  • Do not leave fill: 'forwards' in place; use commitStyles() then cancel()
  • Animate transform and opacity. width/height cause jank
  • Convert coordinates with getBoundingClientRect()
  • getAnimations() controls CSS-defined animations too
  • Check prefers-reduced-motion in JavaScript; falling back to duration: 0 is 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.

ABOUT ME
りん
On this blog, I mainly share information about web development and programming, along with my daily thoughts and what I’ve learned. I aim to create a blog that lets readers enjoy both technology and everyday life, so I also include topics about daily experiences, books, and gourmet. I’d be delighted if you could drop by casually and find something useful or enjoyable here.