Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 17.1Advanced ~24 min · shot 1 of 6

The Canvas Animation Loop

In one line

A canvas keeps no shapes: you wipe it and draw everything again each frame. This topic builds that loop properly: time-based steps, a backing store that matches the screen's pixels, a resize that does not flash, and a loop that sleeps when nobody can see it.

0/6 · 0%

Think of it like this

A flipbook. Every page is a complete drawing, and the page you drew before is still there, not moved. To make a dot travel you cannot slide it along: you draw a new page with the dot a little further on. A canvas is one page that you erase and redraw sixty times a second.

Words you'll meet

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

canvas
An HTML element that holds a grid of pixels you paint with JavaScript. It keeps no shapes, only colours.
backing store
The real pixel grid of a canvas, set by its width and height attributes. CSS only changes how big it is shown.
device pixel ratio
How many screen pixels make one CSS pixel, window.devicePixelRatio. It is 1 on many monitors and 2 or 3 on phones.
dt
Delta time: the milliseconds since the previous frame. Moving by speed × dt keeps speed the same on every screen.
ResizeObserver
A browser object that calls you when an element changes size.
IntersectionObserver
A browser object that tells you when an element enters or leaves the visible part of the page.

Step by step

01Run the dot and read the first line

"Clear, draw, repeat" prints whether the backing store equals the CSS size times the pixel ratio. It is true: fit() set canvas.width itself instead of trusting the default 300 by 150.

02Pause and step

Press Pause, then Step a few times. The dot moves a small, equal amount each step, because every step is speed × dt for one 16.7 ms frame. At 0.1× the same steps are smaller in time, not in distance.

03Compare the two canvases

"Pretend this is a 2× screen" draws the same text twice. The left backing store is half the size it is shown at, so it stretches 2.0×; the right one stretches 1.0×. Look at the thin line and the small text: the left one is soft.

04Send the loop to sleep

"One loop that sleeps" calls setOnScreen(false) and setOnScreen(true); running goes false and true. A real scroll does the same through the observer: scroll the small box up and down.

05Read the order of the frame

Each frame runs the animation callback first, then ResizeObserver callbacks, then paints. That order is why a resize needs its own draw().

Try it yourself

  1. 1

    Make the sharp canvas as soft as the other

    In "Pretend this is a 2× screen" change the second call to paint(document.getElementById("sharp"), 0.5). The console now prints sharp: backing 65x30 shown at 130x60, stretch 2.0x and both canvases are equally soft.

  2. 2

    Slow the clock inside the loop

    In "One loop that sleeps" change Math.min(Math.max(now - last, 0), 50) to Math.min(Math.max(now - last, 0), 5). A 60 Hz frame is 16.7 ms, so each step now counts only 5 ms and the square moves at about a third of its speed. A clamp is a safety net: keep it well above one frame.

  3. 3

    Watch the dot at 0.1×

    In "Clear, draw, repeat" press 0.1× and Play. The dot crawls, and every frame still shows exactly one dot: the clear happens before every draw.

Code & diagrams

Clear, draw, repeat

A time-based loop with a backing store sized to the screen. A ResizeObserver refits and redraws in one step. Pause and step: the dot moves the same small distance per frame.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

backing store matches css size x ratio: true
Pretend this is a 2× screen

The same drawing twice. The left backing store has half the pixels of the size it is shown at: that is the stretch a canvas gets on a 2× phone when the code ignores devicePixelRatio.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

blur: backing 65x30 shown at 130x60, stretch 2.0x
sharp: backing 130x60 shown at 130x60, stretch 1.0x
One loop that sleeps

createLoop() starts, stops and restarts one requestAnimationFrame loop from three reasons: the code wants it, the canvas is on screen, the tab is visible. The calls at the end are synchronous; the real observer takes over after them and stops the loop, because the canvas starts below the fold.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

started, running: true
scrolled away, running: false
scrolled back, running: true
stopped, running: false

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

Refit without redrawing

In "Clear, draw, repeat" change the observer to new ResizeObserver(() => { fit(); }), and add after it: { const probe = canvas.getContext("2d"); let first = true; new ResizeObserver(() => { if (first) { first = false; return; } const d = probe.getImageData(0, 0, canvas.width, canvas.height).data; let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i]) n++; console.log("painted pixels after resize: " + n); }).observe(canvas); setTimeout(() => { canvas.style.width = "200px"; }, 300); }

terminal
$ Run the page and read the console
── what you'll see ──
painted pixels after resize: 0
(with fit(); draw() in the first observer it prints a few hundred, 493 in the latest run: the dot's position decides the exact number)

Myth vs fact

Myth

Setting canvas.width in the HTML makes the canvas bigger on the page.

Fact

It sets the pixel grid. CSS sets the shown size. A 300-pixel canvas styled to 600 CSS pixels is a 300-pixel picture stretched to twice its size.

Myth

A hidden tab freezes the whole page.

Fact

requestAnimationFrame stops, but timers keep running, throttled. A loop that adds a fixed amount per timer tick runs slow in the background, and one that uses the clock jumps forward on return, which is why dt is clamped.

Pro corner

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

  • ▸

    canvas.width = canvas.width is an old way to clear. It also resets the whole context state and can reallocate memory, so use clearRect in the loop and set width only when the size really changes.

  • ▸

    ResizeObserver can report devicePixelContentBoxSize, the element's exact size in device pixels, which avoids a rounding blur at fractional zoom levels. Check browser support before relying on it.

  • ▸

    getImageData makes the browser finish all pending drawing and copy pixels back. It is handy in a test or a measurement (this topic's resize numbers use it) and a frame-time disaster inside a loop.

Remember this

  1. 1

    The problem this solves: the browser animates elements for you (CSS transitions and keyframes, Phases 3 and 4), but a burst of 100 pieces of paper, a field of drifting dots or a drawing that follows the pointer is too many moving things for elements. A <canvas> is a grid of pixels you paint with code, so you control every frame. The price is that you now do what the browser did for free: clearing, timing, sharpness, resizing, pausing, and describing the picture to people who cannot see it.

  2. 2

    The loop. Data Viz Topic 11.6 (/data-viz) drew a chart on a canvas and animated it. Here we go deeper into the loop itself. Every frame has the same four steps: read the clock, move things by the time that passed, clearRect the whole canvas, draw everything. The browser never remembers a shape, so if you skip the clear, last frame's dot stays and the dot smears into a line.

  3. 3

    Move by time, not by frame. requestAnimationFrame hands your callback a timestamp. The step dt is the milliseconds since the last frame, and every move is speed × dt. A screen at 120 Hz then moves the dot the same distance per second as a screen at 30 Hz (Phase 1 and Phase 8 built the same idea into tween() and createTicker()). Clamp dt to about 50 ms, so one very long gap becomes one normal step.

  4. 4

    Backing store versus CSS size. A canvas has two sizes. The width and height attributes are the backing store: how many pixels it really holds. CSS width and height are how big it is shown. If they differ, the browser stretches the pixels and the picture goes soft. A phone with a device pixel ratio of 2 has four screen pixels for every CSS pixel, so a canvas needs cssWidth × devicePixelRatio backing pixels. Then ctx.setTransform(dpr, 0, 0, dpr, 0, 0) lets you keep drawing in CSS pixels.

  5. 5

    Resizing clears the canvas. Setting canvas.width or canvas.height, even to the same number, wipes every pixel and resets the context (transform, colours, line width). A ResizeObserver callback runs after layout and before paint in the same frame, after your animation callback. If it only calls fit(), that frame is painted empty. Measured: with draw() in the callback, a few hundred pixels were painted after a resize (493 in the latest run, 497 in an earlier one: it depends on where the dot is), and without it, 0. Fit and draw in the same callback.

  6. 6

    One loop, and it sleeps. Use one canvas and one loop for all your shapes, not one canvas per shape (every canvas holds its own memory). The browser already stops requestAnimationFrame in a hidden tab, but you should also stop when the canvas is scrolled away: an IntersectionObserver tells you, and document.hidden plus visibilitychange tell you about the tab. A loop that stops must start again with a fresh last time, or its first dt would be the whole sleep. Pixels on a canvas are not in the accessibility tree, so give it role="img" and an aria-label, and offer a pause button for anything that moves longer than 5 seconds.

Explain it without notes

01

Why does a canvas look soft on a phone, and how do you fix it?

02

Why is dt clamped, and what goes wrong without it after a tab switch?

03

What happens to the canvas when its size changes, and what must the resize code do?

Practice

01

A player drags the window from a monitor with ratio 1 to a laptop screen with ratio 2. The canvas goes soft. Write the code that notices and refits.

02

Write an IntersectionObserver that stops a loop when the canvas is less than 10 percent visible and starts it again with a fresh last time.

Trade-offs

  • ↔

    Canvas wins when there are hundreds of moving things or per-pixel control. It loses on text, focus, hit-testing and screen-reader access, which DOM elements give for free: for a handful of shapes, use CSS or SVG.

Done when you can

  • You can write a clear, update, draw loop that moves by dt

  • You can size the backing store for any pixel ratio and draw in CSS pixels

  • You can handle a resize without a blank frame

  • You can stop a loop off-screen and in a hidden tab, and restart it cleanly