Skip to main content

How to Create JavaScript Animations with requestAnimationFrame and the Web Animations API

Introduction

While CSS animations handle most UI motion beautifully, there are situations where you need the full power of JavaScript to drive your animations. Complex physics simulations, interactive canvas graphics, animations that depend on runtime calculations, progress that reacts to user input in real time, or sequences where each frame's output depends on the previous frame's state: these are the domains where JavaScript animations shine.

The cornerstone of JavaScript animation is requestAnimationFrame(), a browser API designed specifically for smooth, efficient animation loops. Unlike setTimeout or setInterval, it synchronizes with the browser's refresh cycle, delivering butter-smooth 60fps motion while automatically pausing when the tab is not visible.

Beyond manual animation loops, modern browsers also offer the Web Animations API (WAAPI), which brings the power of CSS animations into JavaScript with full programmatic control: play, pause, reverse, seek, and dynamically create animations without touching a stylesheet.

In this guide, you will learn how to build animation loops from scratch, create reusable animation functions with custom timing curves, understand performance implications, and leverage the Web Animations API for production-ready animations.

requestAnimationFrame(): The Animation Loop

requestAnimationFrame() tells the browser that you want to perform an animation and requests that the browser call a specified function before the next repaint. The browser then calls your function approximately 60 times per second (matching most displays' 60Hz refresh rate), or at whatever rate the display supports.

Basic Syntax

function animate() {
// Update something on screen
// ...

// Request the next frame
requestAnimationFrame(animate);
}

// Start the animation
requestAnimationFrame(animate);

The browser passes a high-resolution timestamp (in milliseconds, from performance.now()) to your callback:

function animate(timestamp) {
console.log(`Current time: ${timestamp}ms`);
requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Your First JavaScript Animation

Let us move a box across the screen:

<div id="box" style="
width: 80px;
height: 80px;
background: #3498db;
border-radius: 8px;
position: absolute;
top: 50px;
left: 0;
"></div>

<script>
const box = document.getElementById('box');
let position = 0;

function animate() {
position += 2; // Move 2px per frame
box.style.left = position + 'px';

if (position < 500) {
requestAnimationFrame(animate);
}
}

requestAnimationFrame(animate);
</script>

This works, but there is a problem. The animation speed depends on the frame rate. On a 60Hz display, it moves 120px per second. On a 144Hz display, it moves 288px per second. The animation runs at different speeds on different hardware.

Time-Based Animation (The Correct Approach)

To ensure consistent speed regardless of frame rate, base your animation on elapsed time, not frame count:

const box = document.getElementById('box');
const duration = 2000; // 2 seconds
const distance = 500; // 500px
let startTime = null;

function animate(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;

// Calculate progress as a fraction from 0 to 1
const progress = Math.min(elapsed / duration, 1);

// Apply the position based on progress
box.style.left = (progress * distance) + 'px';

if (progress < 1) {
requestAnimationFrame(animate);
}
}

requestAnimationFrame(animate);

Now the box always takes exactly 2 seconds to travel 500px, whether the display runs at 30fps, 60fps, or 144fps. This is the foundation of all correct JavaScript animation.

tip

Always use time-based animation. The timestamp parameter that requestAnimationFrame passes to your callback is your most important tool. Never increment a position by a fixed amount per frame.

Structured Animation Function

Instead of writing custom animation loops every time, let us create a reusable animation function that separates three concerns:

  1. Duration: How long the animation lasts
  2. Timing function: How progress maps to visual change (easing)
  3. Draw function: What to actually render each frame

The animate() Utility

function animate({ duration, timing, draw }) {
const start = performance.now();

function frame(currentTime) {
// fraction goes from 0 to 1
let fraction = (currentTime - start) / duration;
if (fraction > 1) fraction = 1;

// Apply the timing function to get eased progress
const progress = timing(fraction);

// Draw the current state
draw(progress);

// Continue if not finished
if (fraction < 1) {
requestAnimationFrame(frame);
}
}

requestAnimationFrame(frame);
}

Using the Utility

Now animations become clean and declarative:

const box = document.getElementById('box');

animate({
duration: 1000,
timing: (t) => t, // Linear (no easing)
draw: (progress) => {
box.style.left = progress * 500 + 'px';
}
});

Animate multiple properties simultaneously:

animate({
duration: 800,
timing: (t) => t * t, // Ease-in (quadratic)
draw: (progress) => {
box.style.left = progress * 400 + 'px';
box.style.opacity = 1 - progress;
box.style.transform = `rotate(${progress * 360}deg)`;
}
});

Enhanced Version with Callbacks and Cancellation

A more robust version that supports completion callbacks and cancellation:

function animate({ duration, timing, draw, onComplete }) {
const start = performance.now();
let animationId;
let cancelled = false;

function frame(currentTime) {
if (cancelled) return;

let fraction = (currentTime - start) / duration;
if (fraction > 1) fraction = 1;

const progress = timing(fraction);
draw(progress);

if (fraction < 1) {
animationId = requestAnimationFrame(frame);
} else {
if (onComplete) onComplete();
}
}

animationId = requestAnimationFrame(frame);

// Return a cancel function
return {
cancel() {
cancelled = true;
cancelAnimationFrame(animationId);
}
};
}
// Usage with completion callback
const animation = animate({
duration: 1000,
timing: (t) => t,
draw: (progress) => {
box.style.left = progress * 500 + 'px';
},
onComplete: () => {
console.log('Animation finished!');
}
});

// Cancel if needed
document.getElementById('stopBtn').addEventListener('click', () => {
animation.cancel();
});

Promise-Based Version

For modern async workflows, a Promise-based animation function is even more useful:

function animate({ duration, timing, draw }) {
return new Promise((resolve) => {
const start = performance.now();

function frame(currentTime) {
let fraction = (currentTime - start) / duration;
if (fraction > 1) fraction = 1;

const progress = timing(fraction);
draw(progress);

if (fraction < 1) {
requestAnimationFrame(frame);
} else {
resolve();
}
}

requestAnimationFrame(frame);
});
}

// Sequential animations with async/await
async function runSequence() {
const box = document.getElementById('box');

// Step 1: Slide right
await animate({
duration: 600,
timing: easeOut,
draw: (p) => { box.style.left = p * 300 + 'px'; }
});

// Step 2: Fade out
await animate({
duration: 400,
timing: easeIn,
draw: (p) => { box.style.opacity = 1 - p; }
});

// Step 3: Reset and fade in
box.style.left = '0px';
await animate({
duration: 400,
timing: easeOut,
draw: (p) => { box.style.opacity = p; }
});

console.log('Full sequence complete!');
}

Timing Functions: Controlling the Feel of Motion

The timing function transforms the linear time fraction (0 to 1) into a progress value that determines the visual state. This is where you control whether the animation feels mechanical, natural, bouncy, or elastic.

Each timing function takes a value t from 0 to 1 and returns a progress value. When t = 0 the animation starts, when t = 1 it ends, and what happens in between defines the character of the motion.

Linear

The simplest timing function. No acceleration, no deceleration. Output equals input:

function linear(t) {
return t;
}
Progress
1 | *
| *
| * Constant speed
| *
| *
0 *________________
0 1 Time

Use case: Color transitions, progress bars, spinners, situations where constant speed looks natural.

Ease-In (Power Curves)

Starts slow and accelerates. Raising t to a power greater than 1 creates this effect:

// Quadratic ease-in (t²)
function easeInQuad(t) {
return t * t;
}

// Cubic ease-in (t³)
function easeInCubic(t) {
return t * t * t;
}

// Quartic ease-in (t⁴)
function easeInQuart(t) {
return t * t * t * t;
}

// Generic power ease-in
function easeInPower(t, power = 2) {
return Math.pow(t, power);
}
Progress
1 | *
| *
| *
| * Starts slow, accelerates
| *
| *
| *
0 ***______________
0 1 Time

Higher powers create a more dramatic slow start:

animate({
duration: 1000,
timing: easeInCubic,
draw: (progress) => {
box.style.transform = `translateX(${progress * 400}px)`;
}
});

Ease-Out (Deceleration)

Starts fast and decelerates. This is the inverse of ease-in. We can derive any ease-out from its ease-in counterpart:

// Generic ease-out: invert any ease-in function
function makeEaseOut(easeIn) {
return function(t) {
return 1 - easeIn(1 - t);
};
}

// Specific ease-out functions
function easeOutQuad(t) {
return 1 - (1 - t) * (1 - t);
}

function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}

// Or using the generic helper
const easeOutQuart = makeEaseOut(easeInQuart);
Progress
1 | * * * * *
| *
| *
| * Fast start, decelerates
| *
|*
0 *________________
0 1 Time

Use case: Elements entering the screen. The most commonly needed easing for UI animations.

Ease-In-Out (Smooth Both Ends)

Combines ease-in for the first half and ease-out for the second half:

function makeEaseInOut(easeIn) {
return function(t) {
if (t < 0.5) {
return easeIn(2 * t) / 2;
} else {
return 1 - easeIn(2 * (1 - t)) / 2;
}
};
}

function easeInOutQuad(t) {
return t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2;
}

function easeInOutCubic(t) {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}

const easeInOutQuart = makeEaseInOut(easeInQuart);
Progress
1 | * * *
| *
| *
| * Slow start, fast middle, slow end
| *
| *
| * *
0 *________________
0 1 Time

Use case: Elements moving within the viewport, toggling between states.

Bounce

Simulates a ball bouncing when it hits the ground. The animation overshoots, comes back, overshoots less, comes back, and settles:

function bounceOut(t) {
const n1 = 7.5625;
const d1 = 2.75;

if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
}

function bounceIn(t) {
return 1 - bounceOut(1 - t);
}

function bounceInOut(t) {
return t < 0.5
? (1 - bounceOut(1 - 2 * t)) / 2
: (1 + bounceOut(2 * t - 1)) / 2;
}
Progress
1 | * * *
| * *
| *
| * Multiple bounces before settling
| *
| *
| *
0 * *_____________
0 1 Time
// Drop an element with a bounce
animate({
duration: 1200,
timing: bounceOut,
draw: (progress) => {
ball.style.top = progress * 300 + 'px';
}
});

Elastic

Simulates a spring or rubber band. The animation oscillates around the target before settling:

function elasticOut(t) {
if (t === 0 || t === 1) return t;
const p = 0.3; // Period of oscillation
const s = p / 4; // Shift
return Math.pow(2, -10 * t) * Math.sin((t - s) * (2 * Math.PI) / p) + 1;
}

function elasticIn(t) {
if (t === 0 || t === 1) return t;
const p = 0.3;
const s = p / 4;
return -(Math.pow(2, 10 * (t - 1)) * Math.sin((t - 1 - s) * (2 * Math.PI) / p));
}
Progress
1.3| *
1 | * * * * * *
| * *
| * Oscillates around target, then settles
| *
0 *___________________
0 1 Time
// Scale an element with elastic feel
animate({
duration: 1000,
timing: elasticOut,
draw: (progress) => {
button.style.transform = `scale(${progress})`;
}
});

Back (Overshoot)

Pulls back slightly before moving forward, or overshoots the target before settling:

function backIn(t) {
const s = 1.70158; // Overshoot amount
return t * t * ((s + 1) * t - s);
}

function backOut(t) {
const s = 1.70158;
t = t - 1;
return t * t * ((s + 1) * t + s) + 1;
}

function backInOut(t) {
const s = 1.70158 * 1.525;
if (t < 0.5) {
return (Math.pow(2 * t, 2) * ((s + 1) * 2 * t - s)) / 2;
} else {
return (Math.pow(2 * t - 2, 2) * ((s + 1) * (t * 2 - 2) + s) + 2) / 2;
}
}
// Tooltip with pull-back effect
animate({
duration: 500,
timing: backOut,
draw: (progress) => {
tooltip.style.transform = `scale(${progress})`;
tooltip.style.opacity = Math.min(progress * 2, 1);
}
});

Complete Timing Functions Library

Here is a collection you can drop into any project:

const Easing = {
// Linear
linear: (t) => t,

// Quadratic
easeInQuad: (t) => t * t,
easeOutQuad: (t) => 1 - (1 - t) * (1 - t),
easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,

// Cubic
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,

// Sine
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,

// Exponential
easeInExpo: (t) => t === 0 ? 0 : Math.pow(2, 10 * t - 10),
easeOutExpo: (t) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
easeInOutExpo: (t) => {
if (t === 0 || t === 1) return t;
return t < 0.5
? Math.pow(2, 20 * t - 10) / 2
: (2 - Math.pow(2, -20 * t + 10)) / 2;
},

// Circular
easeInCirc: (t) => 1 - Math.sqrt(1 - t * t),
easeOutCirc: (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),
easeInOutCirc: (t) => t < 0.5
? (1 - Math.sqrt(1 - Math.pow(2 * t, 2))) / 2
: (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2,

// Back (overshoot)
easeInBack: backIn,
easeOutBack: backOut,

// Bounce
easeInBounce: bounceIn,
easeOutBounce: bounceOut,

// Elastic
easeInElastic: elasticIn,
easeOutElastic: elasticOut,
};
// Use any easing from the library
animate({
duration: 800,
timing: Easing.easeOutExpo,
draw: (progress) => {
sidebar.style.transform = `translateX(${(1 - progress) * -300}px)`;
}
});

Visualizing Timing Functions

A helpful way to compare timing functions is to animate the same element with each one:

function visualizeEasing(easingName, easingFn, track) {
const dot = document.createElement('div');
dot.className = 'easing-dot';
dot.textContent = easingName;
track.appendChild(dot);

animate({
duration: 2000,
timing: easingFn,
draw: (progress) => {
dot.style.left = progress * 100 + '%';
}
});
}

// Compare multiple easings side by side
const easingsToCompare = [
['linear', Easing.linear],
['easeOutQuad', Easing.easeOutQuad],
['easeOutCubic', Easing.easeOutCubic],
['easeOutExpo', Easing.easeOutExpo],
['easeOutBack', Easing.easeOutBack],
['bounceOut', Easing.easeOutBounce],
['elasticOut', Easing.easeOutElastic],
];

const container = document.getElementById('visualization');
easingsToCompare.forEach(([name, fn]) => {
const track = document.createElement('div');
track.className = 'easing-track';
container.appendChild(track);
visualizeEasing(name, fn, track);
});

cancelAnimationFrame()

Just as requestAnimationFrame() schedules a frame, cancelAnimationFrame() cancels a previously scheduled one. It takes the ID returned by requestAnimationFrame().

Basic Cancellation

let animationId;

function animate(timestamp) {
// ... animation logic ...
animationId = requestAnimationFrame(animate);
}

animationId = requestAnimationFrame(animate);

// Stop the animation
function stop() {
cancelAnimationFrame(animationId);
}

Practical Example: Stoppable Animation

function createAnimation(element, fromX, toX, duration) {
let animationId;
let startTime;
let isRunning = false;

function frame(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const fraction = Math.min(elapsed / duration, 1);
const progress = Easing.easeOutCubic(fraction);

element.style.transform = `translateX(${fromX + (toX - fromX) * progress}px)`;

if (fraction < 1) {
animationId = requestAnimationFrame(frame);
} else {
isRunning = false;
}
}

return {
start() {
if (isRunning) return;
isRunning = true;
startTime = null;
animationId = requestAnimationFrame(frame);
},
stop() {
isRunning = false;
cancelAnimationFrame(animationId);
},
get running() {
return isRunning;
}
};
}

// Usage
const box = document.getElementById('box');
const anim = createAnimation(box, 0, 400, 1000);

document.getElementById('startBtn').addEventListener('click', () => anim.start());
document.getElementById('stopBtn').addEventListener('click', () => anim.stop());

Important: Cancel Before Starting a New Animation

A common bug is starting multiple animation loops on the same element without cancelling the previous one:

// WRONG: Each click starts a new animation loop without stopping the old one
button.addEventListener('click', () => {
requestAnimationFrame(function move(timestamp) {
// ... move the element ...
requestAnimationFrame(move);
});
// Multiple clicks = multiple loops = jittery, fast animation!
});

// CORRECT: Cancel the previous animation before starting a new one
let currentAnimation;

button.addEventListener('click', () => {
if (currentAnimation) {
cancelAnimationFrame(currentAnimation);
}

function move(timestamp) {
// ... move the element ...
currentAnimation = requestAnimationFrame(move);
}

currentAnimation = requestAnimationFrame(move);
});

Performance: requestAnimationFrame vs. setTimeout

Before requestAnimationFrame existed, developers used setTimeout or setInterval for animations. Understanding why requestAnimationFrame replaced them is crucial for writing performant code.

Why setTimeout Is Wrong for Animations

// OLD WAY: Using setTimeout for animation (DO NOT DO THIS)
function animateWithTimeout() {
let position = 0;

function frame() {
position += 2;
box.style.left = position + 'px';

if (position < 500) {
setTimeout(frame, 16); // Roughly 60fps (1000ms / 60 ≈ 16.67ms)
}
}

setTimeout(frame, 16);
}

This approach has multiple problems:

Problem 1: Timing inaccuracy. setTimeout(fn, 16) does not guarantee execution after exactly 16ms. The callback goes into the macrotask queue and executes only after the current execution context is clear. The actual delay can be 16ms, 20ms, 30ms, or more, depending on what else the browser is doing.

Problem 2: Not synchronized with the display. The browser repaints the screen at its own refresh rate (typically every 16.67ms for 60Hz). If your setTimeout fires between repaints, the visual change is wasted because it will only be visible on the next repaint. This causes frame drops and visual jank.

Problem 3: Runs in background tabs. setTimeout continues firing even when the user switches to another tab, wasting CPU and battery.

Problem 4: No automatic throttling. On slower devices that cannot maintain 60fps, setTimeout keeps trying to hit 60fps, causing the browser to struggle and drop frames erratically.

Why requestAnimationFrame Is Right

// CORRECT WAY: Using requestAnimationFrame
function animateWithRAF() {
let startTime;

function frame(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / 2000, 1);

box.style.left = progress * 500 + 'px';

if (progress < 1) {
requestAnimationFrame(frame);
}
}

requestAnimationFrame(frame);
}

Advantage 1: Synchronized with the display. The callback runs right before the browser repaints, ensuring every visual change is actually displayed.

Advantage 2: Automatic throttling. On a 60Hz display, callbacks run approximately every 16.67ms. On a 144Hz display, they run approximately every 6.94ms. On a slow device, the browser reduces the rate to what it can handle without jank.

Advantage 3: Pauses in background tabs. When the user switches tabs, requestAnimationFrame stops firing, saving CPU and battery.

Advantage 4: Batched DOM updates. Multiple requestAnimationFrame calls in the same frame are batched together, and all DOM reads/writes happen in a predictable order.

Side-by-Side Comparison

FeaturesetTimeout(fn, 16)requestAnimationFrame(fn)
Sync with display refreshNoYes
Guaranteed timingNo (at least 16ms)Matched to display
Background tab behaviorContinues runningPauses automatically
Battery/CPU efficiencyPoorExcellent
Frame rate adaptationNoneAutomatic
High-resolution timestampManual Date.now()Provided as parameter
Visual smoothnessInconsistentConsistent

Measuring Actual Frame Rate

You can verify the smoothness of requestAnimationFrame by measuring the actual frame rate:

let lastTime = 0;
let frameCount = 0;
let fps = 0;

function measureFPS(timestamp) {
frameCount++;

if (timestamp - lastTime >= 1000) {
fps = frameCount;
frameCount = 0;
lastTime = timestamp;
console.log(`FPS: ${fps}`);
}

requestAnimationFrame(measureFPS);
}

requestAnimationFrame(measureFPS);
note

Even with requestAnimationFrame, your animation can drop frames if the work inside the callback takes too long. Keep your frame function as lightweight as possible. Avoid heavy DOM queries, complex calculations, or layout-triggering property reads inside the animation loop.

The Web Animations API (WAAPI)

The Web Animations API is a modern browser API that gives you the power of CSS animations with the control of JavaScript. Instead of manually managing requestAnimationFrame loops, timing functions, and frame calculations, WAAPI handles all of that for you while running on the same performant engine that powers CSS animations.

Basic Usage: element.animate()

The entry point is the animate() method available on every DOM element:

const box = document.getElementById('box');

const animation = box.animate(
// Keyframes (array of states)
[
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(300px)', opacity: 0.5 }
],
// Options
{
duration: 1000,
easing: 'ease-out',
fill: 'forwards'
}
);

This is equivalent to defining a CSS @keyframes animation and applying it, but done entirely in JavaScript with full programmatic control.

Keyframe Formats

WAAPI accepts keyframes in two formats:

Array format (most common): Each object is a keyframe, applied evenly across the duration:

element.animate([
{ transform: 'scale(1)', opacity: 1 }, // 0%
{ transform: 'scale(1.2)', opacity: 0.8 }, // 50%
{ transform: 'scale(1)', opacity: 1 } // 100%
], { duration: 500 });

You can set explicit offsets (equivalent to percentage stops in @keyframes):

element.animate([
{ transform: 'translateY(0)', offset: 0 }, // 0%
{ transform: 'translateY(-30px)', offset: 0.4 }, // 40%
{ transform: 'translateY(-15px)', offset: 0.7 }, // 70%
{ transform: 'translateY(0)', offset: 1 } // 100%
], { duration: 800 });

Object format: Properties as keys, arrays of values:

element.animate({
transform: ['translateX(0)', 'translateX(300px)'],
opacity: [1, 0],
offset: [0, 1], // Optional
easing: ['ease-in'] // Per-keyframe easing
}, { duration: 1000 });

Animation Options

The options object supports all the familiar CSS animation properties:

element.animate(keyframes, {
duration: 1000, // milliseconds (not seconds like CSS!)
easing: 'ease-out', // Same values as CSS
delay: 200, // Start delay in ms
endDelay: 100, // Delay after animation ends
fill: 'forwards', // none | forwards | backwards | both
direction: 'alternate', // normal | reverse | alternate | alternate-reverse
iterations: 3, // Number (or Infinity for infinite)
iterationStart: 0.5, // Start mid-way through the first iteration
composite: 'replace', // replace | add | accumulate
pseudoElement: '::before' // Animate pseudo-elements
});
caution

A very common mistake: WAAPI uses duration in milliseconds, while CSS uses seconds. Writing duration: 0.3 in WAAPI means 0.3 milliseconds, which is effectively instant and invisible.

// WRONG: 0.3 milliseconds (basically instant)
element.animate(keyframes, { duration: 0.3 });

// CORRECT: 300 milliseconds
element.animate(keyframes, { duration: 300 });

The Animation Object: Full Control

element.animate() returns an Animation object with methods and properties for complete playback control:

const animation = box.animate(
[
{ transform: 'translateX(0)' },
{ transform: 'translateX(400px)' }
],
{ duration: 2000, easing: 'ease-in-out', fill: 'forwards' }
);

// Playback control
animation.pause(); // Pause at current position
animation.play(); // Resume playing
animation.reverse(); // Play in reverse
animation.cancel(); // Stop and remove effects
animation.finish(); // Jump to end immediately

// Seeking
animation.currentTime = 500; // Jump to 500ms mark

// Speed control
animation.playbackRate = 2; // Double speed
animation.playbackRate = 0.5; // Half speed
animation.playbackRate = -1; // Play backward at normal speed

// Timeline position
animation.startTime; // When the animation started
animation.currentTime; // Current position in ms

// State
animation.playState; // "idle" | "running" | "paused" | "finished"
animation.pending; // true if play/pause is pending

// Promises
animation.ready; // Resolves when ready to play
animation.finished; // Resolves when animation completes

Practical Example: Interactive Playback

const box = document.getElementById('box');

const animation = box.animate(
[
{ transform: 'translateX(0) rotate(0deg)', borderRadius: '8px' },
{ transform: 'translateX(200px) rotate(180deg)', borderRadius: '50%' },
{ transform: 'translateX(400px) rotate(360deg)', borderRadius: '8px' }
],
{
duration: 3000,
easing: 'ease-in-out',
fill: 'forwards'
}
);

animation.pause(); // Start paused

// Play/Pause toggle
document.getElementById('playPause').addEventListener('click', () => {
if (animation.playState === 'running') {
animation.pause();
} else {
animation.play();
}
});

// Reverse
document.getElementById('reverse').addEventListener('click', () => {
animation.reverse();
});

// Speed control
document.getElementById('speed').addEventListener('input', (e) => {
animation.playbackRate = parseFloat(e.target.value);
});

// Scrubber: control animation with a range slider
document.getElementById('scrubber').addEventListener('input', (e) => {
animation.pause();
animation.currentTime = (e.target.value / 100) * 3000;
});

// React to completion
animation.finished.then(() => {
console.log('Animation completed!');
});

Using WAAPI Promises for Sequencing

The .finished promise makes chaining animations elegant:

async function entranceSequence() {
const header = document.querySelector('.header');
const content = document.querySelector('.content');
const footer = document.querySelector('.footer');

// Animate header first
await header.animate(
[
{ opacity: 0, transform: 'translateY(-20px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 400, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' }
).finished;

// Then content
await content.animate(
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 500, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' }
).finished;

// Then footer
await footer.animate(
[
{ opacity: 0 },
{ opacity: 1 }
],
{ duration: 300, fill: 'forwards' }
).finished;

console.log('All animations complete');
}

Staggered Animations with WAAPI

function staggeredEntrance(elements, staggerMs = 50) {
const animations = Array.from(elements).map((el, i) => {
return el.animate(
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{
duration: 400,
delay: i * staggerMs,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'forwards'
}
);
});

// Return a promise that resolves when ALL animations complete
return Promise.all(animations.map(a => a.finished));
}

// Usage
const items = document.querySelectorAll('.list-item');
staggeredEntrance(items, 60).then(() => {
console.log('All items animated in');
});

getAnimations(): Querying Running Animations

You can inspect and control all animations on an element:

// Get all animations on a specific element
const animations = element.getAnimations();
console.log(`${animations.length} animations running`);

// Pause all animations on an element
element.getAnimations().forEach(anim => anim.pause());

// Get ALL animations on the entire page
const allAnimations = document.getAnimations();
console.log(`${allAnimations.length} total animations on page`);

// Pause everything (useful for debugging)
document.getAnimations().forEach(anim => anim.pause());

WAAPI vs. Manual requestAnimationFrame

FeaturerequestAnimationFrameWeb Animations API
Ease of useLow (manual timing, frame management)High (declarative)
Play/Pause/ReverseManual implementationBuilt-in methods
PerformanceGood (manual DOM updates)Excellent (browser-optimized, composited)
Canvas/WebGLRequiredNot applicable
Complex physicsFull controlLimited
SequencingManual with Promises.finished Promise
Browser supportUniversalVery good (all modern browsers)

Use requestAnimationFrame when:

  • Animating Canvas or WebGL
  • Physics simulations (spring, gravity, particles)
  • Each frame depends on complex runtime calculations
  • You need per-pixel control

Use WAAPI when:

  • Animating DOM element CSS properties
  • You need play/pause/reverse/seek
  • Sequencing multiple animations
  • You want browser-level performance optimization

Real-World Example: Combining Everything

Here is a practical example that uses both requestAnimationFrame for a custom canvas particle effect and WAAPI for DOM element animations:

<div id="app">
<canvas id="particles" width="600" height="400"></canvas>
<button id="trigger" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
Click Me
</button>
</div>

<script>
// === Particle system with requestAnimationFrame ===
const canvas = document.getElementById('particles');
const ctx = canvas.getContext('2d');
const particles = [];

class Particle {
constructor(x, y) {
this.x = x;
this.y = y;
this.vx = (Math.random() - 0.5) * 8;
this.vy = (Math.random() - 0.5) * 8;
this.life = 1;
this.decay = 0.01 + Math.random() * 0.02;
this.size = 2 + Math.random() * 4;
this.color = `hsl(${200 + Math.random() * 40}, 80%, 60%)`;
}

update() {
this.x += this.vx;
this.y += this.vy;
this.vy += 0.1; // Gravity
this.life -= this.decay;
}

draw(ctx) {
ctx.globalAlpha = this.life;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}

function particleLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);

for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
particles[i].draw(ctx);

if (particles[i].life <= 0) {
particles.splice(i, 1);
}
}

requestAnimationFrame(particleLoop);
}

requestAnimationFrame(particleLoop);

// === Button animation with WAAPI ===
const button = document.getElementById('trigger');

button.addEventListener('click', async (e) => {
// Spawn particles at click position
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

for (let i = 0; i < 30; i++) {
particles.push(new Particle(x, y));
}

// Animate the button with WAAPI
await button.animate(
[
{ transform: 'translate(-50%, -50%) scale(1)' },
{ transform: 'translate(-50%, -50%) scale(0.9)', offset: 0.2 },
{ transform: 'translate(-50%, -50%) scale(1.05)', offset: 0.6 },
{ transform: 'translate(-50%, -50%) scale(1)' }
],
{
duration: 400,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'
}
).finished;
});
</script>

This example demonstrates how requestAnimationFrame is ideal for the continuously running particle physics simulation, while WAAPI elegantly handles the button's click animation with automatic completion detection.

Summary

JavaScript animations give you full control over motion when CSS alone is not enough. The key concepts are:

requestAnimationFrame() is the foundation. It synchronizes your animation callback with the browser's paint cycle, delivering smooth, efficient frame updates. Always use time-based calculations with the provided timestamp rather than incrementing by a fixed amount per frame.

Structured animation functions separate timing, drawing, and duration into clean, reusable abstractions. The pattern of computing a fraction (0 to 1) from elapsed time, passing it through a timing function, and feeding the result to a draw function keeps your animation code organized and testable.

Timing functions transform linear time into expressive motion. Power curves create ease-in and ease-out effects. Elastic, bounce, and back functions add physical character. Building an easing library gives you a toolkit for any animation scenario.

cancelAnimationFrame() stops scheduled frames. Always cancel previous animations before starting new ones on the same element to avoid stacking loops.

requestAnimationFrame outperforms setTimeout in every way that matters for animation: display synchronization, automatic throttling, background tab pausing, and battery efficiency.

The Web Animations API brings CSS animation capabilities into JavaScript with play, pause, reverse, seek, speed control, and Promise-based completion. Use it for DOM property animations. Reserve requestAnimationFrame for canvas, WebGL, and complex simulations where you need per-frame control.