Topic 11.6
Animation on Canvas
In one line
Animating a canvas is a loop that clears, updates by elapsed time and paints again before every screen refresh. A canvas is one image to a screen reader, so every canvas also needs words and a table.
Think of it like this
A flip book. Each page is a complete drawing, slightly different from the last. Flip fast enough and the eye sees movement. A canvas animation is the same: erase the page, draw the next one, repeat about 60 times a second.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- requestAnimationFrame
- Asks the browser to call your function before the next repaint. Your animation loop.
- frame
- One picture in the animation: one run of the loop.
- delta time (dt)
- The seconds since the last frame. Multiply speeds by it.
- clamp
- Limit a value to a range, here
dtto at most 0.1 seconds. - table twin
- A real HTML table with the same numbers as the chart, for everyone who cannot use the picture.
Step by step
01Move by seconds, not by frames
update(state, dt) adds speed × dt to x. The example calls it for one second of frames at 30, 60 and 144 frames a second. All three end at x = 110. A frame-based move would differ by the frame rate.
02Clamp the pause
One call with dt = 30 (a hidden tab) moves the dot by only 10 pixels, because dt is clamped to 0.1 seconds. The real loop has no such jump.
03Run, stop and restart safely
The loop stops by itself after five frames and logs stopped after 5 frames. The button cancels any running loop, sets last back to null and starts another 120 frames: never two loops at once.
04Many moving dots
10,000 dots are painted twice: one by one (10,000 fill() calls) and in one path with a moveTo before each arc (1 call). The example moves them half a second to the right and checks that none left the canvas.
05Give the picture words and a table
The third example adds role="img", an aria-label with the busiest and quietest day, text inside the canvas, and a table twin inside a <details>. It checks that the table and the picture hold the same numbers.
Try it yourself
- 1
Drop the clamp
Remove
Math.min(dt, 0.1). The pause line printsone frame after a 30 s pause moves the dot by 3000 px (clamped): the dot would jump far off the track. - 2
Remove the moveTo
In the second example delete the
ctx.moveTo(...)line indrawOnePath. There is no error: the picture becomes one solid teal rectangle with a few white specks, because the lines joining each dot to the next are filled as well. The log lines do not change.
Code & diagrams
The log shows counts and positions, never frame times, because real timings differ on every computer.
Preview
Console
Nothing logged yet.Expected console output
30 frames a second, 1 second later: x = 110
60 frames a second, 1 second later: x = 110
144 frames a second, 1 second later: x = 110
one frame after a 30 s pause moves the dot by 10 px (clamped)
stopped after 5 frames; no frame is scheduled nowCounts, not times: how many fill calls each way needs. The picture is a screenful of dots, one fill.
Preview
Console
Nothing logged yet.Expected console output
one path per dot: 10000 fill calls for 10000 dots
one path for all: 1 fill call for 10000 dots
after 0.5 s the first dot moved to x = 128.3 and every dot is still inside the canvas: trueA canvas has no words of its own. The label, the inner text and the table all come from your code. Phase 15.1 shows what a screen reader announces.
Preview
Console
Nothing logged yet.Expected console output
role: img
aria-label: Bar chart of rides per day, 2 to 8 June 2025. Busiest: Tue, 3992. Quietest: Mon, 2979.
table twin rows: 8 (1 header + 7 days)
same numbers in picture and table: true
empty week: No rides to show.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
Start a second loop without cancelling the first
In the first example, remove cancelAnimationFrame(id); from play and click the button twice quickly.
Myth vs fact
Myth
setInterval is as good as requestAnimationFrame.
Fact
Timers drift and run in hidden tabs. requestAnimationFrame follows the screen.
Myth
A canvas is hidden from screen readers, so it is accessible by default.
Fact
It is exposed as an empty graphic with no words. Without a label and a table, a blind user gets nothing from the chart.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
requestAnimationFramecallbacks pause in hidden tabs, so the first frame after returning has a hugedt: that is the reason for the clamp, and for restartinglastwhen you resume. - ▸
prefers-reduced-motion: reduce(check withmatchMedia) lets you skip the animation and draw one final frame. It is a setting some users need for health reasons.
Remember this
- 1
The problem this solves: Phase 9 animates SVG by changing attributes. A canvas has no attributes to change, so motion means redrawing the whole picture every frame. That is cheap enough for thousands of dots, which is why many moving marks belong on a canvas.
- 2
**
requestAnimationFrameis the loop.**requestAnimationFrame(tick)asks the browser to calltick(now)just before the next repaint, withnowin milliseconds. At the end oftickyou ask again. A repaint is the moment the browser draws the page to the screen again. The loop matches the screen's rate (60, 120 or 144 repaints a second) and stops in a hidden tab.cancelAnimationFrame(id)stops it. Never usesetIntervalfor this. - 3
Move by time, not by frame. If a dot moves 2 pixels per frame, it is twice as fast on a screen that repaints 120 times a second (120 Hz) as on one that repaints 60 times. Store the speed in pixels per second and move
speed × dt, wheredtis the seconds since the last frame. The first example runs one second at 30, 60 and 144 frames a second and ends at the same place, x = 110, in all three. (It passesdtin by hand, so it logs no real time.) - 4
Clamp the time step. After 30 hidden seconds the next frame has
dt = 30and the dot would jump 3,000 pixels. UseMath.min(dt, 0.1): the example shows the pause moving it only 10. The first frame has no previous frame, so itsdtis 0. - 5
One loop, always cleared, always cancelled. Each frame:
clearRect, update the state, paint. Keep your data in plain objects, because the canvas remembers nothing. A button that starts a second loop without cancelling the first doubles the work, because every frame is updated and painted twice: callcancelAnimationFrame(id)first, and resetlasttonullso the stale time does not cause a jump. Pausing is the same pair:cancelAnimationFrame(id)to pause, and to resume call the function that setslast = nulland requests a frame, so the time spent paused is never counted. - 6
Many dots: one path. 10,000
beginPath()+arc()+fill()calls are 10,000 fills. Put all dots in one path with amoveTobefore eacharcand callfill()once: the second example counts 10,000 fills against 1. The cost: one fill means one colour. - 7
A canvas is one image to a screen reader. A plain canvas has nothing a screen reader can read: its pixels carry no words, and the shapes you painted are not objects it can list. Give it
role="img"and anaria-labelthat says what the chart is and what stands out, put a short text between the<canvas>tags for old browsers and crawlers, and put the same numbers as a real table next to it (the table twin). Phase 15 goes deep on this. An animation also needs a stop: users who set 'reduce motion' (prefers-reduced-motion) should get a still picture.
Explain it without notes
Why use seconds and dt instead of pixels per frame?
Why clamp dt?
How do you avoid running two animation loops at once?
Practice
Write a loop that moves a dot 120 pixels a second to the right, wraps at x = 300, and stops after 3 seconds of animation time, using dt.
Write describeWeek(week) that returns the text for aria-label, including the busiest day, and handles an empty array.
Trade-offs
- ↔
Redrawing everything every frame is simple and robust, but for a static chart that never changes it wastes battery: draw once and redraw only on a change.
Done when you can
I can write a
requestAnimationFrameloop that clears, updates bydtand paints.I clamp
dt, cancel the old loop and reset the stored time before restarting.I can draw thousands of dots with one path and one
fill().I give every canvas a role, a label with the finding, inner text and a table twin.