Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~23 min· topic 7 of 8

Topic 11.7

requestAnimationFrame and Smooth Animation

In one line

requestAnimationFrame(callback) runs your code right before the browser draws the next frame, in step with the screen; animate by computing each frame from the elapsed time (not by counting frames), change only cheap properties like transform and opacity, and stop with cancelAnimationFrame.

Think of it like this

A flip-book. Each page is a drawing, and flipping quickly makes it look like movement. If you draw a new picture at random moments, some pages get two drawings and some get none, and the movement stutters. requestAnimationFrame is like a friend who taps your shoulder just before each page is flipped: "draw the next picture now". You draw exactly once per page, at exactly the right moment.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Frame
One picture the screen shows. Many frames per second make movement.
Refresh rate
How many frames per second the screen shows, such as 60 Hz or 120 Hz.
Jank
Visible stutter when the browser misses frames because work took too long.
requestAnimationFrame
Asks the browser to run your function once, just before it draws the next frame.
Timestamp
The time in milliseconds passed to the rAF callback, marking the start of the frame.
Easing
A formula that changes the speed of an animation over time, like starting fast and slowing down.
Compositor
The part of the browser that puts layers together on screen, often on the GPU. It can move and fade layers without redoing layout.
Throttle
Limit how often something runs, for example at most once per frame.
prefers-reduced-motion
A user setting (read with a media query) asking sites to use less animation.

Step by step

01Ask for one frame

The callback runs once, before the next paint. Nothing repeats unless you ask again.

once.jswhole filejavascript
requestAnimationFrame((t) => {
  console.log("about to paint, frame time", t);
});

02Loop by asking again

Schedule the next frame from inside the callback. Keep the id so you can stop.

loop.jswhole filejavascript
let id;
function frame(t) {
  update(t);
  id = requestAnimationFrame(frame);
}
id = requestAnimationFrame(frame);
// later: cancelAnimationFrame(id);

03Where rAF sits in a frame

Each frame, the browser runs tasks and microtasks, then, when it's time to draw, all rAF callbacks, then style, layout, paint and compositing. A rAF scheduled inside a rAF callback waits for the next frame.

Where rAF sits in a framediagram
Rendering diagram…

04Compute from elapsed time

Record the start time from the first frame, then compute progress from it in every frame. Stop when progress reaches 1.

tween.jswhole filejavascript
function animate(el, distance, duration) {
  let start;
  function frame(t) {
    start ??= t;
    const p = Math.min((t - start) / duration, 1);
    el.style.transform = "translateX(" + distance * p + "px)";
    if (p < 1) requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

05Throttle busy events to one update per frame

The handler only records the latest value. The rAF callback does the work, at most once per frame.

pointer.jswhole filejavascript
let x = 0, scheduled = false;
window.addEventListener("pointermove", (e) => {
  x = e.clientX;
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    dot.style.transform = "translateX(" + x + "px)";
    scheduled = false;
  });
});

06Respect reduced motion

Check the user's setting and skip to the end state (or use a gentle fade) when they ask for less motion.

motion.jswhole filejavascript
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) el.style.transform = "translateX(200px)";
else animate(el, 200, 600);

Try it yourself

  1. 1

    Try another easing

    In the first example, replace easeOut with linear motion (p) => p, then with easeInOut = (p) => p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2. Predict how each feels, then press Run again.

  2. 2

    Stop it halfway

    Keep the id from requestAnimationFrame in animate, and add a Stop button that calls cancelAnimationFrame(id). Predict: where does the ball stay, and does finished at print?

  3. 3

    Count your screen's frames

    Run the first example and read "Done in N frames". Multiply by 2 to estimate your refresh rate (the animation lasts half a second). On a 120 Hz laptop you'll see about 60.

Code & diagrams

A time-based animation with easing ES2021+ New tab

However many frames your screen draws in half a second (about 30 at 60 Hz, 60 at 120 Hz), the ball arrives at exactly 220 px in 500 ms. The page shows how many frames it took on your device.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

rAF callbacks haven't run yet
finished at translateX(220px)
Per-frame steps vs time-based steps on two screens New tab

Per-frame movement depends on the screen and on dropped frames. Time-based movement covers the same distance in the same time everywhere.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

60 Hz: per-frame moved 120px, time-based moved 200px
120 Hz: per-frame moved 240px, time-based moved 200px
30 Hz: per-frame moved 60px, time-based moved 200px
rAF as a throttle: 50 events, one update New tab

All 50 events arrived before the next frame, so the style was written once, with the latest position. Move your pointer over the grey area: the dot follows, updating at most once per frame.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

events: 50 updates so far: 0
events: 50 updates after the frame: 1

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the stop condition

In the first example, change if (p < 1) { requestAnimationFrame(frame); } else { done(frames); } to always call requestAnimationFrame(frame);.

terminal
$ Page
── what you'll see ──
The ball still stops at 220 px (progress is capped at 1), but `finished at` never prints, and the callback keeps running every frame forever. In DevTools → Performance you'd see a rAF callback in every frame.

Break #2

Animate `left` instead of `transform`

Animate a large, complex element with el.style.left = x + "px" (and position: relative) instead of transform.

terminal
$ DevTools → Performance
── what you'll see ──
Purple "Layout" and green "Paint" blocks in every frame; on slower phones, dropped frames (red marks) and visible stutter.

Myth vs fact

Myth

requestAnimationFrame always runs 60 times per second.

Fact

It runs at the display's refresh rate (often 120 Hz or more), slower when frames are dropped, and not at all in background tabs. Base movement on elapsed time.

Myth

Calling requestAnimationFrame once starts an animation loop.

Fact

Each call schedules exactly one callback. A loop exists only because the callback calls rAF again.

Myth

JavaScript animation is always worse than CSS animation.

Fact

For simple transitions CSS (or element.animate) is easier and can run on the compositor. JavaScript with rAF is the right tool when each frame needs logic, and animating transform there is smooth too.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    rAF callbacks are run in the update the rendering step of the HTML event loop, after tasks and microtasks and before style recalculation. A callback that takes 30 ms delays that frame's paint, so heavy work should be split across frames or moved to a Web Worker.

  • ▸

    In cross-origin iframes that are off screen, Chrome may throttle rAF and timers to save power. Testing animation inside embeds needs the iframe to be visible.

  • ▸

    will-change: transform hints the browser to give an element its own compositor layer in advance. Use it on the few elements that really animate; every layer costs GPU memory.

  • ▸

    For animations that should follow scrolling exactly, CSS scroll-driven animations (animation-timeline: scroll()/view()) run off the main thread and replace many rAF + scroll listener patterns (supported in Chromium and Safari 26; check current support for Firefox).

Remember this

  1. 1

    A screen shows a new image many times a second: its refresh rate, usually 60 Hz (60 frames per second, one every ~16.7 ms), and 90, 120 or 144 Hz on many phones and monitors. Each image is a frame. Smooth animation means changing the page once per frame and finishing that work quickly. If your work takes longer than the frame's time budget, the browser misses frames, which users see as jank (stutter).

  2. 2

    requestAnimationFrame(callback) (rAF for short) asks the browser to call callback once, just before it calculates styles, layout and paint for the next frame. It returns an id number, and cancelAnimationFrame(id) cancels it. To animate continuously, the callback schedules itself again: function frame(t) { ...; requestAnimationFrame(frame); }. The callback receives a timestamp t in milliseconds (the same clock as performance.now()), marking the start of this frame; every rAF callback in the same frame gets the same timestamp.

  3. 3

    Why not setInterval(step, 16)? Timers aren't tied to the screen: they drift, can fire twice in one frame or not at all, run at the wrong rate on a 120 Hz screen, and keep running in background tabs wasting battery. rAF matches the display's real rate, and browsers pause it in background tabs and hidden iframes. That pause matters: an animation must cope with a big gap between frames when the tab comes back.

  4. 4

    So animate by time, not by frame count. Moving "2 px per frame" goes twice as fast on a 120 Hz screen and slows down when frames are dropped. Instead, compute progress from the time since the animation started: progress = Math.min((t - start) / duration, 1), then position = from + (to - from) * progress. The animation takes the same time on every device, and the last frame lands exactly on the end value. An easing function reshapes progress for natural motion, for example easeOut = (p) => 1 - (1 - p) ** 3 (fast start, gentle stop).

  5. 5

    What you change matters as much as when. Changing left, top, width or height forces layout (recalculating positions of everything affected) and then paint. Changing transform (move, scale, rotate) and opacity can usually skip both and be handled by the compositor (the part of the browser that assembles layers, often on the GPU), so they stay smooth even when the main thread is busy. Inside a frame, do all reads (measurements) first and all writes (style changes) after, to avoid layout thrashing (Topic 8.8).

  6. 6

    rAF is also a throttle: events like pointermove or scroll can fire many times per frame, but the screen only updates once per frame. Store the latest value in the event handler and do the expensive update in one rAF callback per frame. For simple transitions (hover effects, fades, slides) prefer CSS transitions and animations or the Web Animations API (element.animate(keyframes, options)); use rAF when each frame needs JavaScript: physics, games, canvas drawing, following the pointer. Always respect users who ask for less motion with the prefers-reduced-motion media query.

Explain it without notes

01

What does requestAnimationFrame do, and why is it better than setInterval for animation?

02

Why should animations be based on elapsed time rather than frame count?

03

Which CSS properties are cheapest to animate, and why?

04

How can rAF be used to throttle high-frequency events?

Practice

01

Write fadeIn(el, duration) that animates opacity from 0 to 1 with rAF and logs faded when done.

02

Write a countUp(el, to, duration) that animates a number in el.textContent from 0 to to with whole numbers, ending exactly on to.

Trade-offs

  • ↔

    rAF gives full control per frame but runs on the main thread, so a busy page can still stutter; CSS and Web Animations on transform/opacity can keep running on the compositor but can't run arbitrary logic each frame.

  • ↔

    Throttling to one update per frame keeps the page smooth but drops intermediate values; for drawing apps that need every point, keep all points (or use getCoalescedEvents()) and draw them in the frame.

Done when you can

  • I can schedule and cancel frames with requestAnimationFrame and cancelAnimationFrame.

  • I write animation loops that stop themselves.

  • I compute animation progress from elapsed time and can apply an easing function.

  • I animate transform and opacity instead of layout properties.

  • I can throttle busy events to one update per frame.

  • I respect prefers-reduced-motion.