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
documentwhen 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
Make the loop slower
In the first example change
elapsed / 400toelapsed / 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
Force the reduced path
In the second example change both
reducedvalues totrue. The two lines now both sayjumped to 200(the second still starts with the labelreduced = false:, which is now wrong) and the bar is at 200 right away.
Code & diagrams
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
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: trueOn 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
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: trueBreak 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.
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).
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-motionis 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
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
**
requestAnimationFrame(rAF) is the browser's frame clock.**requestAnimationFrame(fn)asks the browser to callfnonce, just before the next screen refresh, with a timestamp. To animate you call it again from insidefn.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
**
d3.timeris a small wrapper over it.**d3.timer(callback)callscallback(elapsed)on every frame with the milliseconds since the timer started, until you calltimer.stop(). It shares one rAF loop between all timers (transitions are timers).d3.timerfalls back to asetTimeoutfor 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.requestAnimationFrameitself passes a timestamp that counts from when the page started, so with plain rAF you remember the first timestamp and subtract it;d3.timerdoes that subtraction for you. The first example places a dot withelapsed, and stops itself. - 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
**
prefers-reduced-motionis 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 withmatchMedia("(prefers-reduced-motion: reduce)").matchesand 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
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
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
setIntervalthat 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 tovisibilitychangeondocument, readdocument.hidden, and stop the timer when it istrue, start it again when it isfalse. The second example does that too and counts the frames added while "hidden".
Explain it without notes
Why should an animation be placed by elapsed time and not by counting frames?
What is the difference between requestAnimationFrame and setInterval(fn, 16)?
What does prefers-reduced-motion: reduce mean, and what should a chart do about it?
Practice
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?
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
requestAnimationFrameandd3.timerdo and how each is stopped.I place things by elapsed time and can explain why.
I can read
prefers-reduced-motionand say what to turn off and what to keep.I stop loops when
document.hiddenis true.I can say what the frame budget is at 60 Hz.