19 Choosing the medium 19 Choosing the medium 19 Choosing the medium 19 Choosing the medium
What moves, and how many of it, picks the medium. The motion itself is the same in every one.
Every motion in this book has been written down somewhere. The menus and cards are elements, animated by the browser’s own animation engine. The particles and noise of chapters 14 to 18 are painted on a canvas by code, one frame at a time. The browser offers several ways to make things move, and they are not interchangeable. Each is cheap for some jobs and costly for others.
Two questions decide which one to use: what is moving, and how many of it. By the end you will have a short list of questions that picks the medium for any motion you are asked to build.
A change, and a process
Four small pieces of interface. Hover or click one to play it again, and notice that for each you could say the start and the end in a few words.
Each one is a single element moving between two states you can name: off and on, closed and open. Describe the two ends and the curve between them, and the browser draws every frame in between. You write no loop. That is the job CSS was made for.
Now a flock.
Watch it for a few seconds. Each bird steers by what its neighbours are doing, so where it goes next depends on where every other bird is now. There is no end state to write down. Code works out every bird, every frame, and paints the result. Nothing on this canvas is an element. The flock is pixels, redrawn from numbers.
A state change is described once. A process is computed every frame. Most motion sits somewhere between the two, and where it sits, together with how many things are moving, picks the medium.
Which one is falling behind?
Both dots make the same trip on the same curve, over the same time. Press play on each and watch the motion, not the clock. One of them is having trouble.
Which one is the page struggling to draw?
How many
Now make it happen for real. This figure moves the same dots three ways: as elements moved with transform, as elements moved with left and top, and as circles painted on a single canvas. Start on left and top and drag the count up until the motion stutters like A. Then switch modes at that same count. Look for which modes are still smooth, then read the numbers to see how far apart they are.
The readout is measured on your machine while you watch, so it will differ from anyone else’s. Frame is the time from one frame to the next, averaged over the last 30. While the page keeps up, it sits at your display’s frame length: 16.7ms at 60Hz, 8.3ms at 120Hz. When the page falls behind, frames are dropped and the number climbs.
The three modes differ in how much work each frame asks for:
- DOM,
leftandtop. Every dot is an element. Each frame the browser works out every dot’s style again, then its layout, becauseleftandtopare layout properties, then paints the result. More dots, more of all of it. - DOM,
transform. Still one element and one style per dot, every frame. But a transform moves an element after layout is done, so the layout step is skipped. Chapter 20 opens up that pipeline. - Canvas 2D. One element. The dots are numbers in a loop, added to one path and filled once. The browser has a single element to update; the rest is your loop and the drawing, and each extra dot adds little.
So left and top usually give out first, and the canvas last. Where each one gives out depends on your machine: a few hundred moving elements can be too many on a phone, while a canvas can take thousands. Treat those as rough orders of size and measure yours.
That doesn’t make the canvas the better medium. A canvas is a picture: it knows nothing about what it drew. There is no hover, no focus, no text to select, nothing for a screen reader, no layout. (Hit-testing, working out which drawn thing is under the pointer, is yours to write.) For a handful of things the DOM does all of that already, and on a canvas you would build it yourself. The canvas wins when there are more things than elements can carry.
Five media
Five media cover almost all motion on the web. Cost means two things here: what the medium costs the browser, and what it costs you.
| Medium | Best for | Cost |
|---|---|---|
| CSS | UI state changes, hovers, enters and exits | cheapest, declarative |
| WAAPI | timelines controlled from JavaScript, scrubbing | low |
| SVG | line drawing, morphing shapes, crisp diagrams | medium, and it grows with the number of shapes |
| Canvas 2D | many objects, particles, procedural and generative motion | you own everything |
| WebGL / WebGPU | tens of thousands of things, shaders, 3D | the highest complexity |
CSS describes a change: from here to there, over this long, on this curve. The browser has the whole animation before it starts, so no code of yours has to run on each frame, and for transform and opacity the browser can even run it off the main thread (chapter 20). Use transitions between two states and keyframes when there are more. It is the cheapest to write and the easiest to get right, and for a few elements changing state it is nearly always the answer. In practice most of your motion will be CSS.
WAAPI, the Web Animations API, is the same engine, reached from JavaScript. element.animate() returns an Animation you can pause, reverse, scrub, speed up, and wait for. Use it when code has to hold the motion: a slider scrubs it, a gesture interrupts it, a sequence needs to know when it ends. Libraries such as Motion and GSAP work on the same elements and add what the platform leaves out, like springs, timelines, and layout animation.
SVG is vector drawing inside the DOM. Its shapes stay sharp at any zoom, and CSS and WAAPI animate them like any other element, so everything above still applies. It is the medium for line drawing (a stroke’s dash offset, animated), shapes that change into other shapes, icons, and diagrams. But every shape is an element, so a thousand shapes cost what a thousand elements cost: the DOM curve from figure 19.3 again.
Canvas 2D is one element holding a bitmap that your code paints. Every frame you clear it and draw again, so you own everything: the loop, the timing, hit-testing, text, sharpness on high-density screens. In return, thousands of simple things cost little each. Particles, flocks, generative drawings, anything computed frame by frame: the whole of Part VII.
WebGL and WebGPU program the graphics card directly. You write shaders, small programs that run in parallel for every vertex or every pixel. That is how you move tens of thousands of things, run per-pixel effects, or draw in 3D. It is the most power and the most code, and it is usually reached through a library such as three.js.
To choose, ask these in order and stop at the first yes:
- Is it a few elements changing state? CSS.
- Does code need to hold it, to pause, reverse, scrub, sequence, or follow a finger? WAAPI, or a library.
- Is it a drawing, like strokes, icons, or a diagram? SVG.
- Is it hundreds or thousands of simple things, or motion computed every frame? Canvas 2D.
- Is it tens of thousands, per-pixel effects, or 3D? WebGL or WebGPU.
Each step down gives you more control and hands you more work. Go no further than the motion needs.
Two cases to try it on. A dropdown menu opening: one element, two states, so question 1, CSS. A progress ring that fills as a file uploads, with a Cancel button that must reverse it: a drawing, but code has to hold it, so question 2 comes first and you would drive an SVG stroke with WAAPI. The order matters: the first yes wins, and a later question only matters when the earlier ones say no.
One page can mix them. A chart can draw its axes and labels in SVG, where they stay sharp and the text can be selected, and put ten thousand points on a canvas behind them.
When you own the loop
Choosing a canvas makes the frame budget yours (chapter 20 measures it). Three habits keep you inside it.
Batch. Every drawing call has a cost of its own, apart from the pixels it fills, so 3,000 separate fill() calls cost far more than one call that fills 3,000 circles. Put shapes that share a style into one path and fill it once. The canvas in figure 19.3 draws every dot this way: one beginPath(), one circle per dot, one fill(). A single fill has a single colour, so group shapes by colour: one path per colour, not one per shape.
Make no garbage per frame. An object literal, an array from map() or filter(), a new closure: anything created inside the loop is garbage a frame later. The engine collects it when it chooses to, and part of that work pauses your code. Make enough garbage every frame and sooner or later a pause costs you a frame. So allocate once, before the loop. Keep positions and velocities in typed arrays such as Float32Array, update objects in place, and when a particle dies, keep its slot for the next one to be born.
Take the drawing off the main thread. canvas.transferControlToOffscreen() hands a canvas over to a Web Worker. The worker gets its own 2D context and its own requestAnimationFrame, and does the drawing. The canvas stays in the page and shows what the worker drew. Now the drawing no longer shares a thread with the rest of your page: a heavy frame in the worker doesn’t delay a click, and a busy main thread doesn’t stop the drawing. The price is that a worker has no DOM, so pointer positions, sizes, and anything else it needs must be posted to it.
One motion, five notations
The desk starts with this site’s --ease-out over 280ms: a 240px move, written five ways. Read across the cards before you change anything, and find the same three numbers (240, 280, and the curve) in each. CSS and WAAPI say it in nearly the same words. Motion and GSAP count in seconds, not milliseconds. The canvas version has to bring its own clock and its own curve.
Then change one thing. Type spring.snappy into Any easing and convert. CSS and WAAPI can only play the spring’s shape, sampled into linear() over a fixed duration. Motion takes the spring’s physics directly. GSAP needs a helper to sample it. On the canvas it becomes three lines of physics, run in fixed 1ms steps through each frame. Notice which notations stay short and which grow: that growth is what each medium asks you to own.
The notation
The same small move twice: first as a state change in CSS, then as an animation your code holds. Then the canvas habits from above, in code.
/* Name both ends; the browser draws the frames between. */
.box {
transform: translateX(0);
transition: transform 280ms cubic-bezier(.2, .8, .2, 1);
}
.box.is-on {
transform: translateX(240px);
} // The same move, but now it's an object your code holds.
const box = document.querySelector('.box');
const slider = document.querySelector('input[type="range"]'); // min 0, max 280
const button = document.querySelector('button');
const move = box.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(240px)' }],
// fill: 'both' keeps the start and end poses when it is not running,
// so scrubbing to 0 or 280 shows the right picture.
{ duration: 280, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'both' },
);
// Scrub: stop its clock and set the time yourself, in ms.
slider.addEventListener('input', () => {
move.pause();
move.currentTime = slider.valueAsNumber;
});
// Change your mind: play it backwards from wherever it is.
button.addEventListener('click', () => move.reverse()); // Many dots, one canvas. Everything is allocated once, in setup().
// Each frame is arithmetic on typed arrays, one path and one fill:
// no new objects, so nothing for the garbage collector to clean up.
// Written for the Lab's Canvas sandbox: it calls setup(), update() and
// draw(), and supplies TAU, seeded(), rand, random() and pencils.
const N = 3000;
function setup(w, h) {
rand = seeded(19);
const s = {
w, h,
x: new Float32Array(N), y: new Float32Array(N),
vx: new Float32Array(N), vy: new Float32Array(N),
};
for (let i = 0; i < N; i++) {
const a = random(0, TAU), speed = random(20, 90);
s.x[i] = random(0, w);
s.y[i] = random(0, h);
s.vx[i] = Math.cos(a) * speed;
s.vy[i] = Math.sin(a) * speed;
}
return s;
}
function update(s, dt) {
for (let i = 0; i < N; i++) {
s.x[i] += s.vx[i] * dt;
s.y[i] += s.vy[i] * dt;
// Off one edge, back in at the other.
if (s.x[i] < 0) s.x[i] += s.w; else if (s.x[i] > s.w) s.x[i] -= s.w;
if (s.y[i] < 0) s.y[i] += s.h; else if (s.y[i] > s.h) s.y[i] -= s.h;
}
}
function draw(ctx, s, w, h) {
ctx.clearRect(0, 0, w, h);
ctx.beginPath(); // one path for every dot
for (let i = 0; i < N; i++) {
ctx.moveTo(s.x[i] + 2, s.y[i]); // start each circle on its own edge
ctx.arc(s.x[i], s.y[i], 2, 0, TAU);
}
ctx.fillStyle = pencils.ink;
ctx.fill(); // one fill for all of them
} // main.js: size the canvas, then give it away.
const canvas = document.querySelector('canvas');
// dpr: device pixels per CSS pixel. Capped at 2 so the bitmap stays affordable.
const dpr = Math.min(2, devicePixelRatio);
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('worker.js');
// Listing it in the second argument transfers it: the worker owns it now.
worker.postMessage({ canvas: offscreen, dpr }, [offscreen]);
// A worker has no DOM, so input is posted to it.
canvas.addEventListener('pointermove', (e) => {
worker.postMessage({ x: e.offsetX, y: e.offsetY });
}); // worker.js: draws on its own thread, with its own animation frames.
let ctx, dpr;
let last = 0;
const pointer = { x: 0, y: 0 }; // made once, updated in place
const dot = { x: 0, y: 0 };
self.onmessage = (e) => {
if (e.data.canvas) {
ctx = e.data.canvas.getContext('2d');
dpr = e.data.dpr;
requestAnimationFrame(frame);
} else {
pointer.x = e.data.x;
pointer.y = e.data.y;
}
};
function frame(now) {
const dt = last ? (now - last) / 1000 : 0;
last = now;
// Ease toward the pointer, the same at any frame rate (chapter 15).
// 12 is the stiffness: higher catches up faster (about two thirds of the gap
// closes in 1/12 s, 83ms). k is the fraction of the gap to close this frame.
const k = 1 - Math.exp(-12 * dt);
dot.x += (pointer.x - dot.x) * k;
dot.y += (pointer.y - dot.y) * k;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels; the canvas has dpr times more
ctx.clearRect(0, 0, ctx.canvas.width / dpr, ctx.canvas.height / dpr);
ctx.fillStyle = '#FF3B1F';
ctx.beginPath();
ctx.arc(dot.x, dot.y, 8, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(frame);
} The worker’s loop is the same requestAnimationFrame loop as in chapter 14, because dedicated workers have requestAnimationFrame too. Only the input arrives differently, as messages.
On Monday: start with CSS, reach for WAAPI when a script must hold the motion, and move to canvas only when you have measured that elements can’t carry the count. Whichever medium you pick, each frame still has to be finished before the screen asks for it. How do you find out whether yours is? Chapter 20 shows where to click in DevTools to measure it.
Judge the motion
The medium decides what a motion costs. It doesn’t decide whether the motion is right. Two versions of the same move: pick the better one, then say why in one word.
Two versions. Which is better? Then say why, in one word.Taste: judging, then naming the reason.
5 trials. Judge with your eyes first; the numbers come after. Three right in a row makes trials harder, a miss eases them.