Shots in this scene
The requestAnimationFrame Loop Done Right
In one line
A hand-written animation loop needs four habits: add up elapsed time instead of counting frames, share one loop between all animations, clamp the gap after a tab switch, and stop when nothing is left to move.
Think of it like this
A band plays to one conductor. If every musician brings their own metronome, the tempos drift, and a musician who starts two metronomes plays everything twice as fast. One conductor gives one beat, and when the music ends the conductor puts the baton down. A shared animation loop is that conductor.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- requestAnimationFrame
- A browser function that runs your callback once, just before the next paint. Call it again inside the callback to keep a loop going.
- timestamp
- The number the browser passes to the callback: milliseconds since the page started, taken at the start of the frame. Every callback in one frame gets the same value.
- delta time (dt)
- The time since the previous frame in milliseconds, about 16.7 at 60 Hz. Moving by speed × dt keeps the motion the same on every screen.
- ticker
- One shared loop that calls a list of functions every frame and ends itself when the list is empty.
- clamp
- Force a number into a range: Math.min(Math.max(n, low), high). Here it stops a huge or negative dt.
- subscriber
- A function added to the ticker. It runs every frame until it returns false or is removed.
Step by step
01Run the shared loop and read the console
"One loop for two slides" starts two slides with one ticker. The first console line says subscribers: 2 | loop running: true: two functions share one loop. There are no tracks on the timeline, because tracks list CSS and Web Animations API animations, not loops written by hand.
02Pause and step the loop
Press Pause, then Slide again, then Step a few times. Each step runs one frame, and the orange dot moves by the same small amount: dt is the same every frame. The timeline's clock also drives requestAnimationFrame, so your loop is as steppable as a CSS animation.
03Watch the loop end itself
Let both slides finish. The orange dot is done at 300 ms and the blue one at 500 ms, and one frame after the blue one the console prints subscribers: 0 | loop running: false. Nobody is left, so the ticker stopped requesting frames.
04Meet the four traps
"Four ways a loop goes wrong" prints its results as lines. Two loops started for one element, the clamp of an 8-second gap, a timestamp just before last, and a loop for an element that was removed.
Try it yourself
- 1
Clamp too hard and watch time slow down
In "One loop for two slides", change
createTicker()tocreateTicker({ maxDelta: 5 }). Every frame can now add at most 5 ms, and a 60 Hz frame is about 16.7 ms, so time runs at about a third of its speed. In a real run the orange slide, which should take 300 ms, finished after about 1000 ms and the blue one, 500 ms long, after about 1700 ms. A clamp is a safety net: set it well above one frame. - 2
Count the loops
In "Four ways a loop goes wrong", press Start both again. The orange dot (two loops adding to one position, 4 px a frame) arrives after about 400 ms, and even overshoots to 102 px, because each loop checks
x < 100before the other has added its 2. The blue dot (one guarded loop, 2 px a frame) arrives after about 830 ms (50 frames). Double speed is the sign of a loop started twice.
Code & diagrams
createTicker() is the new code in loop.js. Each slide adds up dt and returns false when it is done. Press Pause, then Slide again, then Step.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
subscribers: 2 | loop running: true
orange done
blue done
subscribers: 0 | loop running: falseThe two loops in the first case both do x += 2 on one shared number, so the box moves twice as fast. The guarded version stops the old loop before it starts a new one.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
naive, two clicks -> loops running: 2
guarded, two clicks -> subscribers: 1
normal frame, 16 ms gap -> 16
back from another tab, 8 s gap -> 50
timestamp just before last -> 0
first frame with last = 0 -> 50
after the element left the page -> subscribers: 0Break 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 to return false
In "One loop for two slides", delete the line return false; // done: leave the loop.
Myth vs fact
Myth
requestAnimationFrame runs 60 times a second.
Fact
It runs once per screen refresh: 30, 60, 90, 120 or 240 times a second depending on the device, not at all in a hidden tab, and sometimes at a lower rate to save power. That is why you add up dt instead of counting calls.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The ticker copies its set (
[...subs]) before the loop, so a subscriber that adds or removes subscribers cannot change the set while it is being walked. - ▸
A hand-written loop ignores
prefers-reduced-motion: askmotionAllowed()(Phase 7) before you start it. Do reads and writes in separate passes. ReadinggetBoundingClientRect()after a style write forces layout (Topic 6.4). One ticker can run a read phase, then a write phase, for every subscriber.
Remember this
- 1
The problem this solves: the
tweenfrom Phase 1 starts its ownrequestAnimationFrameloop for each animation. That is fine for one loading bar. On a page with ten moving things, a double click, or an element that is removed mid-animation, you get loops that fight over one value, loops that run for nothing, and no single place to stop, slow or measure them. - 2
Recap, then deeper. The JavaScript course (Topic 11.7) showed that
requestAnimationFrame(callback)runs your function just before the next paint and passes a timestamp. Deeper: that timestamp is the start of the current frame, the same for every callback in it. Use it, notDate.now(). - 3
Delta time. The gap between two frames is
dt = now - last. Moving something byspeed × dteach frame, or addingdtto an elapsed-time counter, makes it last the same time at 30, 60 and 120 Hz (Topic 1.3). Phase 1'stweeninstead measuresnow - startfrom one fixed start. Adding updtis the better habit when speed can change while running (springs in Phase 9, slow motion, a draggable) because each frame only needs the last step, not the whole history. - 4
One loop for many animations.
createTicker()(below) keeps a set of subscribers and runs onerequestAnimationFrameloop for all of them. The browser would run ten callbacks in one frame anyway, so the gain is not speed. It is control: one place to clamp time, to pause everything, to count what is running, and to guarantee that the loop ends when the last subscriber leaves. - 5
Clamp the gap. A background tab does not get animation frames. When the learner comes back, the first timestamp is seconds later, and
dtwould be seconds. Move something byspeed × 8000 msand it teleports; a physics model explodes (Topic 9.2). The ticker clampsdtto 0..50 ms, which also fixes two small traps: the very first frame (wherelastmust be set when the loop starts, ordtis "now minus 0") and a timestamp that lands a hair beforelast, which would makedtnegative. - 6
The trade-off of clamping. Adding up clamped
dtmeans the animation *pauses* while the tab is hidden and continues where it was. Phase 1's absolutetween*jumps to the end* when you return. A loading bar tied to real time should jump, a bouncing ball should pause: decide on purpose. - 7
Stop when done, stop when gone. A subscriber returns
falsewhen it finishes, and the ticker lets the loop end when the set is empty. A loop that never ends drains a phone's battery. Keep the function thatadd()returns and call it when the component leaves, or checkel.isConnectedinside the callback. And guard against double starts: before starting a loop for an element, stop the one it already has.
Explain it without notes
Why add up dt instead of counting frames or using one fixed start time?
What does clamping dt fix, and what does it change about the animation?
What does one shared loop give you that ten separate loops do not?
Practice
Add pause() and resume() to createTicker. While paused, no subscriber runs and no frames are requested, and when resumed the first dt must not include the pause.
Write slideTo(el, x) on the ticker so that calling it again while the element is moving cancels the first move and continues from the current position.
Done when you can
You can explain why a loop adds up dt instead of counting frames
You can say what the clamp fixes and what it changes
You can start, stop and guard a loop so double clicks and removed elements leave nothing running
You know when to use a loop and when to hand the work to CSS or the Web Animations API