jsguides

The Web Animations API: Keyframe Animations in JavaScript

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. The keyframes argument is an array of objects representing the states to animate through, while options controls timing, easing, and repetition. Here is a complete example with three keyframes:

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'. The keyframes array is the heart of any WAAPI call. It supports three distinct formats, giving you flexibility depending on how complex your keyframe timing needs to be:

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 });

The array syntax is straightforward when you want evenly-spaced keyframes. If you are animating multiple properties in sync, the object notation can be more concise by mapping each property to an array of values:

el.animate({
  opacity: [0, 1, 0],
  transform: ['scale(1)', 'scale(1.2)', 'scale(1)']
}, { duration: 600, iterations: 3 });

Both of the previous formats spread keyframes evenly across the duration. When you need fine-grained control, such as a longer pause at the start or a quick exit at the end, explicit offsets let you set the exact timing for each keyframe.

Offset values range from 0 to 1. Any keyframe without an explicit offset gets one assigned evenly between its neighbors.

el.animate([
  { opacity: 0, offset: 0 },
  { opacity: 1, offset: 0.8, easing: 'ease-out' },
  { opacity: 0, offset: 1 }
], { duration: 1000 });

The array and object notations handle the most common cases. When you need precise control over the pacing, explicit offsets let you specify the exact position and easing for each keyframe. Any keyframe without an explicit offset gets an evenly-distributed slot between its neighbors:

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:

OptionDefaultDescription
duration0Length in milliseconds
delay0Start delay in milliseconds
fill'none''none', 'forwards', 'backwards', 'both'
iterations1Number of repetitions (use Infinity for looping)
direction'normal''normal', 'reverse', 'alternate', 'alternate-reverse'
easing'linear'CSS easing function name
iterationStart0Offset 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'
});

The table above covers every option you can pass, and the looping example shows a common pattern. Once an animation is running, you often need more than just start-and-wait. The Animation object that animate() returns gives you complete playback control — pause, resume, seek, reverse, and speed changes are all first-class operations:

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();

The playback methods cover pausing, seeking, and reversing, but you will often want to run code when an animation finishes. WAAPI gives you two ways to listen for the completion event — a promise-based approach and a standard event listener:

animation.finished.then(() => {
  console.log('Animation done');
});

// Or via event listener
animation.addEventListener('finish', () => {
  console.log('Animation finished');
});

The playback controls and completion events give you full command of a running animation. The fill option is the next piece — it determines what the element looks like when the animation is not actively running, which matters for both the start delay and the aftermath:

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, which is useful for responsive enter animations
el.animate(keyframes, { duration: 500, delay: 200, fill: 'both' });

The fill examples show each mode one at a time. Beyond single-element animations, WAAPI can coordinate multiple animations against the document timeline. This is useful when you want several elements to fire in sequence or in sync:

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 });

The timeline-based scheduling is handy for coordinated sequences. You can also pause every active animation in one shot by listening for page visibility changes, which saves battery and avoids jank on hidden tabs:

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    document.getAnimations().forEach(anim => anim.pause());
  } else {
    document.getAnimations().forEach(anim => anim.play());
  }
});

The timeline approach gives you precise control over when each animation starts. If you want a simpler pattern for list animations, where each item enters slightly after the previous one, a staggered delay is often cleaner to write and easier to read. The staggered pattern also works well for card layouts and notification lists:

Staggered Animations

A common pattern is staggering, where 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'
  });
});

Staggered animations work well when you want a list to appear with a cascading feel. If the same keyframe definition needs to be shared across several elements, avoid duplicating the configuration. Create a KeyframeEffect once and reuse it:

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 is flexible — you can define the animation shape without binding it to a specific element yet. When you pass null as the target, the effect becomes a template that any element can adopt by calling animate() with it:

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 because 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 since they go through the main thread.

Picking the right motion

The Web Animations API is most valuable when the motion is part of the interface, not just decoration. A button that reacts to state, a panel that slides into place, or a list that appears with a subtle stagger all benefit from a direct JavaScript animation API. The browser can manage the timing and playback while your code focuses on when the animation should happen. That separation keeps the animation logic close to the interaction that triggered it, which makes the whole feature easier to understand.

It is also useful when the animation needs to be controlled after it starts. Pause, reverse, speed changes, and cancellation are all simpler when you have a live Animation object. That makes WAAPI a better fit than a pure CSS keyframe when the app needs to respond to user input mid-flight. If the motion is simple and never changes, CSS may still be the cleaner choice. WAAPI shines when the animation is part of the app’s logic.

Keeping motion cheap

Animations are easiest to keep smooth when they stay away from layout-heavy properties. transform and opacity are usually the safest options because they do not force the browser to recalculate the full page layout. That matters on busy screens where the main thread is already doing other work. The more the animation depends on width, height, or other layout values, the more likely it is to compete with the rest of the page.

This does not mean you can never animate other properties. It means you should choose them intentionally. A small state change might justify it, but a large sequence of layout changes can become costly. A good habit is to ask whether the motion can be expressed as movement or fading before reaching for size changes. That simple question often leads to cleaner, smoother results.

Motion As Feedback

Animations are often most useful when they tell the user what just happened. A quick reveal, a gentle emphasis, or a clear return to the resting state can make an interface feel easier to follow. The point is not to animate everything. The point is to give the user a small cue that helps the next action feel obvious. When motion supports the interaction instead of distracting from it, the interface feels calmer and more coherent.

Final motion note

A motion system stays easier to use when the animations match the meaning of the UI. Use movement to guide attention, not to decorate every state change. If the animation does not help the user understand what just happened, it is probably extra noise. Keeping that line clear makes the feature calmer and the code easier to maintain.

Good motion should feel like part of the interaction rather than a separate layer added on top. When it does, the interface feels more coherent and the animation work becomes easier to maintain.

That kind of restraint also makes later tweaks simpler because the animation is doing one job instead of many.

When the motion stays focused, the page is easier to scan and the animation code is easier to change later.

That balance is what keeps motion useful instead of noisy.

It is the difference between helpful feedback and extra decoration.

And that difference is what people remember when the interface feels smooth.

Small motion choices make a big difference in how calm the page feels.

That calmness is part of good UI work.

That keeps the animation from becoming decoration for its own sake and helps the rest of the interface stay focused.

Small, meaningful motion is usually the part people notice most.

It gives the page a little feedback without taking over the experience.

See Also