Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~23 min· topic 5 of 6

Topic 9.5

The Animation Loop and Reduced Motion

In one line

Everything that moves on a web page is drawn in frames. requestAnimationFrame and d3.timer call your function once per frame, and your function should place things by elapsed time, not by counting frames. Respect prefers-reduced-motion, and stop loops when the page is hidden.

Think of it like this

A film projector. It shows a fixed number of pictures per second, and the film only looks right if each picture is the right distance along the story for its moment. If you speed up the projector and keep the same pictures, the story runs fast. A web page is a projector whose speed you do not control: 60 pictures a second on one screen, 144 on a gaming monitor, 30 on a slow phone. Your animation must say where things are at a time, not in a frame.

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. At 60 Hz there are 60 per second.
requestAnimationFrame
A browser function that calls your function just before the next screen refresh. Call it again from inside to keep going.
elapsed time
The milliseconds since the animation started. Placing things by it makes the speed the same on every screen.
prefers-reduced-motion
A setting in the operating system, exposed as a media query, that asks sites to avoid movement.
visibilitychange
The event on document when the page becomes hidden (another tab, a minimised window) or visible again.

Step by step

01See why frame counting fails

The first example simulates one second on a 30, a 60 and a 144 Hz screen. Counting frames moves the dot 150, 300 and 720 px. Counting time moves it 300 px each time.

02Run the real loop

A d3.timer places a dot with elapsed ÷ 400 for 400 ms, stops itself, and logs the final position. Whether the machine draws 30 or 144 frames, the dot ends at the same place.

03Ask the reader

The second example starts with one function with two paths: jump when reduced is true, slide when it is false. In your dashboard the flag comes from matchMedia, read at the moment of the update.

04Stop when hidden

The second example also listens to visibilitychange, stops its timer when the page is hidden and starts it again when it is visible. It counts the frames added in each phase: none while hidden.

Try it yourself

  1. 1

    Make the loop slower

    In the first example change elapsed / 400 to elapsed / 200. The loop now takes half the time and the dot still ends at 320: the end position depends on the rule, not on the number of frames.

  2. 2

    Force the reduced path

    In the second example change both reduced values to true. The two lines now both say jumped to 200 (the second still starts with the label reduced = false:, which is now wrong) and the bar is at 200 right away.

Code & diagrams

Place by time, not by frame: three screens, then the real loop New tab

The simulated lines are arithmetic, so they are the same on every machine. The number of real frames is not logged: it depends on the screen. Only the end position is, and it does not.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

 30 Hz | per-frame: 150 px | per-time: 300 px | frame budget: 33.3 ms
 60 Hz | per-frame: 300 px | per-time: 300 px | frame budget: 16.7 ms
144 Hz | per-frame: 720 px | per-time: 300 px | frame budget: 6.9 ms
loop stopped itself, dot at 320 | drew more than one frame: true
One function, two paths: jump or slide, and stop when hidden New tab

On your own computer the first line of the real code reads your setting. Switch it in your system's accessibility settings (or in the browser's developer tools, under Rendering, "Emulate CSS media feature prefers-reduced-motion") and run the page again.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

the setting is read with: (prefers-reduced-motion: reduce) | matches is a boolean
reduced = true:  jumped to 200 | attribute right away: 200
reduced = false: slid to 200
a change of the setting while the page is open is heard with query.addEventListener('change', fn): function
frames while visible > 0: true
hidden: loop stopped
frames added while hidden: 0
visible: loop started
frames added after coming back > 0: true

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

Count frames instead of time

In the first example replace the loop's body by dot.attr("cx", +dot.attr("cx") + 5); and stop after 60 frames.

terminal
$ In the preview
── what you'll see ──
The dot reaches 320 after one second on a 60 Hz screen, after two seconds on a 30 Hz one, and after 0.4 seconds on a 144 Hz one. Nothing in the Console says so.

Break #2

Forget to stop the timer

In the second example delete stop(); inside the visibility handler's first branch (the one that runs when hidden).

terminal
$ Console
── what you'll see ──
hidden: loop stopped
frames added while hidden: 6
(the first line is printed, but the timer was not stopped; the number depends on your machine, only "above 0" is the point. The line "visible: loop started" never appears, because the timer variable is still set.)

Myth vs fact

Myth

Reduced motion means no animation at all.

Fact

It means no large movement. A short fade or a colour change still tells the reader something changed. Never remove the change itself: the new value must still appear.

Myth

setInterval(fn, 16) is the same as requestAnimationFrame.

Fact

It is not tied to the screen refresh, so it can draw twice in one frame or miss one, and it keeps running in hidden tabs. rAF is synchronised with the display.

Pro corner

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

  • ▸

    Reduced motion is also an accessibility standard, not only politeness. WCAG 2.2 has 2.2.2 Pause, Stop, Hide (level A: movement that starts automatically and lasts more than 5 seconds needs a way to pause it) and 2.3.3 Animation from Interactions (level AAA: motion caused by an interaction can be turned off, unless it is essential). A 0.5 s slide is not covered by 2.2.2, but a looping animation is; honouring prefers-reduced-motion is the usual way to meet 2.3.3.

  • ▸

    To test reduced motion, use the developer tools (Rendering, then the media feature emulation), or in an automated test call page.emulateMedia({ reducedMotion: "reduce" }) in Playwright. A chart that takes the flag as an argument can be tested without either.

Remember this

  1. 1

    The problem this solves: code that moves a dot 5 pixels per frame crosses the screen twice as fast on a 120 Hz display as on a 60 Hz one, and runs in slow motion when the computer is busy. And an animation the reader cannot stop, or one that keeps running on a hidden tab, is rude and wastes battery.

  2. 2

    **requestAnimationFrame (rAF) is the browser's frame clock.** requestAnimationFrame(fn) asks the browser to call fn once, just before the next screen refresh, with a timestamp. To animate you call it again from inside fn. cancelAnimationFrame(id) stops it. The browser pauses rAF in hidden tabs, which is the first reason animations that use it cost almost nothing in the background.

  3. 3

    **d3.timer is a small wrapper over it.** d3.timer(callback) calls callback(elapsed) on every frame with the milliseconds since the timer started, until you call timer.stop(). It shares one rAF loop between all timers (transitions are timers). d3.timer falls back to a setTimeout for waits longer than 24 ms and uses rAF only when a frame is near, and it checks once a second whether the clock jumped (for example after a laptop slept) and corrects for it. requestAnimationFrame itself passes a timestamp that counts from when the page started, so with plain rAF you remember the first timestamp and subtract it; d3.timer does that subtraction for you. The first example places a dot with elapsed, and stops itself.

  4. 4

    Place by time, not by frame count. The first example simulates one second on three displays. A rule "5 px per frame" moves the dot 150, 300 and 720 px on 30, 60 and 144 Hz. A rule "0.3 px per millisecond" moves it 300 px on all three. The frame budget is the other side of the same fact: at 60 Hz a frame is 16.7 ms and all your work (JavaScript, style, layout and paint) must fit in it, at 144 Hz only 6.9 ms. When it does not fit, the browser skips frames: the animation stutters, but with time-based placement it is still in the right place.

  5. 5

    **prefers-reduced-motion is a request from the reader.** People with vestibular disorders, migraines or attention difficulties can ask their operating system for less motion. The browser exposes it as the media query (prefers-reduced-motion: reduce). Read it with matchMedia("(prefers-reduced-motion: reduce)").matches and in CSS with @media. What to turn off: movement across the screen (slides, bounces, zooms, parallax, large position changes), looping animation, and anything that flashes. What can stay: a fade, a colour change, a short highlight, because they do not move things across the field of view. The information must still arrive, just without the motion.

  6. 6

    The setting can change while the page is open, so a chart that stored it at start is wrong after the change. Read it at the moment you animate, or listen: query.addEventListener("change", (e) => ...). The preview here cannot change your system's setting, so the example passes the answer in as a parameter: the same pattern makes the code testable.

  7. 7

    Stop loops when the page is hidden. A loop that runs in a hidden tab, or a dashboard on a wall screen that is covered, wastes battery. rAF itself pauses in hidden tabs (and so do transitions, which run on it), but a setInterval that fetches data does not (it is only slowed down), and a transition's clock keeps counting, so on return it may already have ended. Listen to visibilitychange on document, read document.hidden, and stop the timer when it is true, start it again when it is false. The second example does that too and counts the frames added while "hidden".

Explain it without notes

01

Why should an animation be placed by elapsed time and not by counting frames?

02

What is the difference between requestAnimationFrame and setInterval(fn, 16)?

03

What does prefers-reduced-motion: reduce mean, and what should a chart do about it?

Practice

01

Write animate(duration, draw) with d3.timer: it calls draw(k) with k from 0 to 1 by elapsed time, stops itself at 1 and returns a promise. Why is a promise useful here?

02

Write motion(ms) that returns 0 when reduced motion is requested and ms otherwise, and use it in bar.transition().duration(motion(300)). What happens with a duration of 0?

Trade-offs

  • ↔

    A time-based loop is always correct and costs a little arithmetic per frame. A frame-based loop is simpler and wrong on every screen that is not the author's.

Done when you can

  • I can say what requestAnimationFrame and d3.timer do and how each is stopped.

  • I place things by elapsed time and can explain why.

  • I can read prefers-reduced-motion and say what to turn off and what to keep.

  • I stop loops when document.hidden is true.

  • I can say what the frame budget is at 60 Hz.