Complete JavaScript Course Module 8: Intermediate: The DOM (Document Object Model)

JavaScript Animations: requestAnimationFrame and the Web Animations API

  • Beginner
  • Lesson 8.4
  • 8 min read
  • 35 of 76 in the course

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:

ToolBest for
requestAnimationFrameAnimations 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:

OptionExampleMeaning
duration500Length in milliseconds
delay200Wait before starting
iterations3 or InfinityHow 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 propertyWhat 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
playbackRateSpeed: 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 to element.style or 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 setInterval for animation; it is not in step with the screen and wastes battery in hidden tabs.
  • requestAnimationFrame runs your code before each frame; base movement on the timestamp and stop with cancelAnimationFrame.
  • element.animate(keyframes, options) creates keyframe animations from JavaScript and returns an Animation object.
  • Control playback with pause, play, reverse, playbackRate and the finished promise.
  • Animate transform and opacity, and tone down movement when prefers-reduced-motion is set.