In short
JavaScript animations make elements move, fade and grow on the page. Learn smooth animation with requestAnimationFrame, the Web Animations API with element.animate(), and reduced motion. Why not animate with setInterval?
JavaScript animations make elements move, fade and grow on the page. Learn smooth animation with requestAnimationFrame, the Web Animations API with element.animate(), and reduced motion.
Why not animate with setInterval?
When you first think of animation in JavaScript, you might try moving an element a little every few milliseconds with setInterval. It works, but it looks and behaves badly:
- It is not in step with the screen. Your screen redraws itself at a fixed rate, usually 60 times per second (some screens 120 or more). A timer runs at its own rhythm, so some frames get two moves and others none. The motion looks jumpy, which developers call jank.
- It keeps running in hidden tabs, wasting battery for an animation nobody can see.
- Timers can run late when the browser is busy, so the speed changes.
Before writing any JavaScript, ask whether CSS can do the job. A simple hover effect or a class that triggers a CSS transition is often all you need. Use JavaScript animations when the movement depends on values you only know at runtime, when you need to control playback (pause, reverse, speed), or when you need to know when an animation ends.
JavaScript gives you two good tools:
| Tool | Best for |
|---|---|
requestAnimationFrame | Animations you calculate yourself frame by frame: games, following the mouse, physics, canvas drawing |
Web Animations API (element.animate()) | Keyframe animations like CSS animations, but created and controlled from JavaScript |
Smooth movement with requestAnimationFrame
requestAnimationFrame(callback) (often shortened to rAF) asks the browser: "Run this function just before you draw the next frame." The browser calls it once, in step with the screen, and pauses automatically in background tabs. To keep animating, your function asks for the next frame again.
The browser passes your function a timestamp: the current time in milliseconds. Use it to base the movement on time, not on frame count. Then the box moves at the same speed on a 60 Hz laptop and a 120 Hz phone.
<!DOCTYPE html>
<html lang="en">
<body>
<div id="box" style="width: 60px; height: 60px; background: teal; border-radius: 8px;"></div>
<button id="start">Start</button>
<button id="stop">Stop</button>
<script>
const box = document.querySelector("#box");
const speed = 150; // pixels per second
const distance = 300; // where to stop
let startTime = null;
let frameId = null;
function step(timestamp) {
if (startTime === null) startTime = timestamp; // remember when we began
const seconds = (timestamp - startTime) / 1000; // time passed so far
const x = Math.min(seconds * speed, distance); // never go past the end
box.style.transform = `translateX(${x}px)`;
if (x < distance) {
frameId = requestAnimationFrame(step); // ask for the next frame
} else {
console.log("Arrived");
}
}
document.querySelector("#start").addEventListener("click", () => {
cancelAnimationFrame(frameId); // stop any animation already running
startTime = null;
frameId = requestAnimationFrame(step);
});
document.querySelector("#stop").addEventListener("click", () => {
cancelAnimationFrame(frameId); // cancel the next scheduled frame
});
</script>
</body>
</html>
Arrived
Click "Start" and the box glides 300 pixels to the right in 2 seconds, then logs "Arrived". "Stop" freezes it where it is. requestAnimationFrame returns an ID, and cancelAnimationFrame(id) cancels that scheduled frame, just like clearTimeout.
We animate transform instead of left or margin because the browser can move a transformed element without recalculating the layout of the whole page. transform and opacity are the two cheapest properties to animate.
Tip: Always use the timestamp, not a fixed step like
x += 3. A fixed step makes the animation twice as fast on a 120 Hz screen and slower when the computer is busy.
The Web Animations API: element.animate() with keyframes and options
For keyframe animations (go from this look to that look), writing the frame-by-frame maths yourself is unnecessary. The Web Animations API lets you call element.animate(keyframes, options). It is the same engine that runs CSS animations, so it is smooth and efficient.
- Keyframes are an array of objects. Each object lists CSS properties (in camelCase) for one point of the animation.
- Options is an object with the timing, or just a number of milliseconds.
const card = document.createElement("div");
card.textContent = "Welcome back, Sara!";
card.style.cssText = "padding: 16px; background: #fde68a; border-radius: 8px; max-width: 240px;";
document.body.append(card);
const fadeIn = card.animate(
[
{ opacity: 0, transform: "translateY(20px)" }, // start
{ opacity: 1, transform: "translateY(0)" }, // end
],
{ duration: 500, easing: "ease-out" }
);
fadeIn.finished.then(() => console.log("Card is visible"));
// Output (after half a second): Card is visible
The card slides up and fades in over half a second. animate() returns an Animation object, and its finished property is a promise that resolves when the animation ends, so you can also await it.
You can add more keyframes, and use offset (from 0 to 1) to say when a keyframe happens. Here is a "pop" for an add-to-cart button:
const button = document.createElement("button");
button.textContent = "Add to cart";
document.body.append(button);
button.addEventListener("click", () => {
button.animate(
[
{ transform: "scale(1)" },
{ transform: "scale(1.2)", offset: 0.3 }, // biggest at 30% of the time
{ transform: "scale(1)" },
],
{ duration: 300, easing: "ease-in-out" }
);
});
Every click makes the button grow quickly and shrink back. The most useful options are:
| Option | Example | Meaning |
|---|---|---|
duration | 500 | Length in milliseconds |
delay | 200 | Wait before starting |
iterations | 3 or Infinity | How many times to repeat |
direction | "alternate" | Play forwards, then backwards, and so on |
easing | "ease-out", "linear", "cubic-bezier(...)" | The speed curve |
fill | "forwards" | Keep the final keyframe's styles after the end |
Playback controls: play, pause, reverse and speed
Because animate() gives you an object, you can control the animation while it runs:
| Method or property | What it does |
|---|---|
pause() / play() | Pause and continue |
reverse() | Play backwards from the current point |
cancel() | Stop and remove all its effects |
finish() | Jump to the end |
playbackRate | Speed: 2 is double speed, 0.5 half speed |
playState | "running", "paused", "finished" or "idle" |
<!DOCTYPE html>
<html lang="en">
<body>
<div id="spinner" style="width: 50px; height: 50px; border: 6px solid #ccc; border-top-color: royalblue; border-radius: 50%;"></div>
<button id="toggle">Pause</button>
<button id="faster">Faster</button>
<script>
const spinner = document.querySelector("#spinner");
const toggle = document.querySelector("#toggle");
const spin = spinner.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ duration: 1000, iterations: Infinity }
);
toggle.addEventListener("click", () => {
if (spin.playState === "running") {
spin.pause();
toggle.textContent = "Play";
} else {
spin.play();
toggle.textContent = "Pause";
}
console.log(spin.playState);
});
document.querySelector("#faster").addEventListener("click", () => {
spin.playbackRate *= 2;
console.log(`Speed: ${spin.playbackRate}x`);
});
</script>
</body>
</html>
paused
running
Speed: 2x
The loading spinner turns forever. The first button pauses and resumes it, and each click on "Faster" doubles the speed.
Combining JavaScript animations with CSS classes
The Web Animations API works well together with your CSS classes. A common pattern is: animate, wait for finished, then change the real state with a class or by removing the element.
const notice = document.createElement("p");
notice.className = "notice";
notice.textContent = "Your settings were saved.";
document.body.append(notice);
async function dismiss(element) {
const fadeOut = element.animate(
[{ opacity: 1 }, { opacity: 0, transform: "translateX(40px)" }],
{ duration: 300, easing: "ease-in" }
);
await fadeOut.finished; // wait until the animation is done
element.remove(); // then really remove it
console.log("Notice removed");
}
setTimeout(() => dismiss(notice), 1500);
// Output (after about 2 seconds): Notice removed
The message appears, then after 1.5 seconds slides out to the right, fades and is removed from the page. Without await, the element would vanish instantly before anyone saw the animation.
Use this split as a rule of thumb: put states in CSS classes (like .is-open or .is-hidden, with CSS transitions), and use animate() for one-off effects like a shake, a pop or an exit animation.
Common mistake: Using
fill: "forwards"to keep the end state forever. The animation then keeps overriding your styles, and later changes toelement.styleor classes seem to do nothing. It is better to set the final style yourself (or add a class) when the animation finishes.
Respecting reduced motion
Some people get dizzy or sick from movement on screen, and they turn on a "reduce motion" setting in their operating system. Your JavaScript can read this setting with matchMedia, the JavaScript version of a CSS media query:
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
function showCard(element) {
if (reduceMotion.matches) {
element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 }); // gentle fade only
} else {
element.animate(
[{ opacity: 0, transform: "translateY(30px) scale(0.9)" }, { opacity: 1, transform: "none" }],
{ duration: 500, easing: "ease-out" }
);
}
}
console.log(reduceMotion.matches);
// Output: false (or true if reduced motion is turned on)
When reduced motion is on, we keep a short fade instead of the sliding and scaling. Remove big movement, parallax and endless animations in that case, but you do not need to remove every small change. Learn more on MDN's Web Animations API page.
Practice exercise
Make a login form with an email input and a "Sign in" button. When the form is submitted with an empty email, prevent the default action and make the input shake: animate transform through translateX(0), translateX(-8px), translateX(8px), translateX(-8px) and translateX(0) over 400 ms. If the user prefers reduced motion, change the border colour to red instead of shaking.
Hint: call input.animate(keyframes, 400) inside the submit listener. Check matchMedia("(prefers-reduced-motion: reduce)").matches first.
Key takeaways
- Avoid
setIntervalfor animation; it is not in step with the screen and wastes battery in hidden tabs. requestAnimationFrameruns your code before each frame; base movement on the timestamp and stop withcancelAnimationFrame.element.animate(keyframes, options)creates keyframe animations from JavaScript and returns an Animation object.- Control playback with
pause,play,reverse,playbackRateand thefinishedpromise. - Animate
transformandopacity, and tone down movement whenprefers-reduced-motionis set.