The transform matrix: moving the coordinate system, not the shapes
There are no shapes on a canvas to move, so translate, rotate, and scale don't touch your drawing at all — they multiply into a hidden current transformation matrix that every coordinate you later pass to fill, stroke, or drawImage gets run through on its way to the pixel buffer, and getting comfortable with that matrix is what makes rotating something around an arbitrary point stop feeling like a trick.
The transform matrix: moving the coordinate system, not the shapes
ctx.translate(100, 0) looks like it moves something. It doesn't — there's nothing to move. The flagship lesson already told you the canvas has no shapes, only pixels and the commands that painted them. So what does translate actually do, if not move a shape that doesn't exist?
Here's the mechanism: the context holds a current transformation matrix (the CTM), a 2D affine transform applied to every coordinate you pass to a drawing command before it reaches the pixel buffer, and translate, rotate, and scale don't replace that matrix — they multiply into it. You never move a shape. You move the coordinate system the next shape gets drawn into, and the drawing commands themselves never change.
The CTM: six numbers standing in for every coordinate you write
Internally the CTM is a 3x3 matrix, but the bottom row is always fixed at 0, 0, 1 for an affine 2D transform, so the API exposes it as six numbers: a, b, c, d, e, f. Every (x, y) you hand to fillRect, arc, lineTo, or drawImage gets run through that matrix before it becomes a device-buffer coordinate:
deviceX = a * x + c * y + e;
deviceY = b * x + d * y + f;a and d carry scale, b and c carry rotation and skew, and e and f carry translation. The identity matrix — 1, 0, 0, 1, 0, 0 — leaves every coordinate untouched, which is the CTM every fresh context starts with. Nothing about fillRect(10, 10, 50, 50) changes when the CTM isn't identity; the call still says "draw a 50 by 50 square at 10, 10." What changes is where 10, 10 and 50, 50 actually land once the matrix runs.
translate, rotate, and scale compose — they don't replace
ctx.translate(x, y), ctx.rotate(radians), and ctx.scale(sx, sy) each build a small matrix for just that operation, then multiply it into the existing CTM rather than overwriting it. The CTM after several calls is the product of every matrix so far, applied in the order you called them:
Matrix multiplication is not commutative, so order matters — the same two calls in the opposite order produce a different matrix, and a different drawing. This is the identical non-commutativity the GSAP transforms lesson walks through for CSS transform strings, for exactly the same underlying reason: each operation transforms the coordinate space that every operation after it runs inside.
Concretely: rotate always rotates around whatever the current origin is — the point that (0, 0) currently maps to, after every prior transform. If you rotate first, you spin the whole coordinate grid around wherever the origin already was — usually the canvas's top-left corner, which sends your shape swinging along a wide arc. If you translate first, you relocate the origin to the shape's own position, and then rotating spins the coordinate grid around that new, local origin — which is what makes the shape spin in place instead of swinging around the corner of the canvas.
// rotate-then-translate: rotates around the CANVAS's origin (top-left),
// so "move 100 along local x" now points wherever the rotation sent it —
// the square swings out on an arc, well away from (100, 0).
ctx.rotate(Math.PI / 4);
ctx.translate(100, 0);
ctx.fillRect(-25, -25, 50, 50);
// translate-then-rotate: moves the origin to (100, 0) FIRST, so the
// rotation that follows spins the square in place, around its own center.
ctx.translate(100, 0);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-25, -25, 50, 50);Rotating around an arbitrary point
That mechanism is also the recipe for rotating around any point you choose, not just the canvas's corner: translate the origin to that point, rotate, then draw the shape offset from the new origin by however far it should sit from the pivot (or translate back if you want to keep drawing in the original coordinate frame afterward).
function drawSpinningLabel(cx, cy, angle) {
ctx.save(); // push a copy of the current CTM
ctx.translate(cx, cy); // origin now sits at the pivot point
ctx.rotate(angle); // rotation happens around THIS origin
ctx.fillRect(-15, -15, 30, 30); // draw at LOCAL coordinates, as if at 0, 0
ctx.restore(); // pop back to the CTM from before this shape
}This is the canonical canvas pattern, and it's worth naming as one unit: save, translate to the object's position, rotate or scale, draw the object at local coordinates as though it sat at the origin, restore. save() and restore() — covered in full in the drawing state machine lesson — push and pop exactly this CTM (along with the rest of the context's style state), which is what lets you transform for one object and cleanly hand back an untouched coordinate system for the next one. Without the save/restore pair, every subsequent shape you draw would keep compounding onto this object's translate and rotate, which is the transform-matrix version of the missing-beginPath bug from the previous lesson — state that was supposed to be scoped to one shape leaking into the next.
setTransform, transform, and resetTransform
Three more calls round out the CTM API, and each does something distinctly different from translate/rotate/scale:
setTransform(a, b, c, d, e, f)does not compose — it replaces the CTM outright with the matrix you specify, discarding whatever was there. This is exactly the call the pixel backing store lesson uses to bake the device pixel ratio into the CTM once, up front:ctx.setTransform(dpr, 0, 0, dpr, 0, 0)makes every coordinate you write in CSS pixels land correctly in a backing buffer that's actuallydprtimes larger, without you multiplying every single drawing coordinate bydpryourself.transform(a, b, c, d, e, f)is the general composing form — liketranslate/rotate/scale, it multiplies the matrix you supply into the existing CTM rather than replacing it.translate,rotate, andscaleare really just convenience wrappers that build a specificathroughfand call this.resetTransform()sets the CTM back to the identity matrix directly, a shortcut forsetTransform(1, 0, 0, 1, 0, 0).getTransform()reads the current CTM back out as aDOMMatrix, useful for inspecting what the accumulated transforms have produced, or for saving a matrix outside thesave/restorestack.
// Baking devicePixelRatio into the CTM once, instead of scaling every
// coordinate you draw with by hand:
const dpr = window.devicePixelRatio;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // REPLACES the CTM
// From here on, write ordinary CSS-pixel coordinates:
ctx.fillRect(10, 10, 50, 50); // lands correctly on a dpr-scaled backing storeThe 2D cousin of the model matrix
If any of this feels familiar from a different module, it should: this is the exact same idea as the model matrix in the three.js MVP chain, just with two dimensions instead of three and no perspective divide. There, a mesh's local-space vertices got multiplied by a 4x4 model matrix built from that object's position, rotation, and scale before they meant anything in the shared world. Here, the coordinates you write in a drawing call are the "local space," and the CTM — built the same way from translate, rotate, and scale, just as a 3x3 affine matrix instead of a 4x4 one — is what places them in the canvas's shared device-pixel space. Three.js hands you that matrix machinery through .position/.rotation/.scale on every object; the canvas hands you the identical machinery through translate/rotate/scale/setTransform calls on the context. Fewer dimensions, no w component to worry about, same underlying move: don't move the geometry, move the space the geometry is measured in.
Where this goes next
The CTM decides where a coordinate lands, but it says nothing about how the resulting pixels combine with whatever was already painted underneath them — whether a semi-transparent shape blends with the background or replaces it outright, and what it even means to draw "behind" something already on the canvas. Compositing and blending is next: the rules the rasterizer follows the instant transformed coordinates turn into actual painted pixels.
Go deeper
- MDN — CanvasRenderingContext2D.setTransform() — The call that replaces the CTM outright, including the devicePixelRatio pattern this lesson ties to the pixel backing store lesson.
- MDN — CanvasRenderingContext2D.transform() — The general composing form that translate, rotate, and scale are convenience wrappers around.
- MDN — DOMMatrix — The object getTransform() returns, and the general representation of the a-through-f affine matrix this lesson works through by hand.
- HTML Living Standard — canvas transformations — The specification's own definition of the current transformation matrix and how translate, rotate, scale, and transform each update it.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What does ctx.translate(100, 0) actually change, given that the flagship lesson established there are no shapes on a canvas to move?
- Write out the two-line formula for how a, b, c, d, e, f turn an (x, y) you pass into a device-buffer coordinate.
- Explain why translate-then-rotate and rotate-then-translate produce different drawings for the same two calls, in terms of what 'the current origin' means at the moment rotate runs.
- Walk through the save, translate, rotate, draw-local, restore pattern and say what save() and restore() are specifically pushing and popping.
- How would you rotate a shape around an arbitrary point that isn't the canvas's origin? Name the sequence of calls.
- Distinguish setTransform() from transform(): which one replaces the CTM and which one composes into it?
- Explain how this lesson's CTM parallels the model matrix from the three.js MVP-matrices lesson, and name one way the 2D version is simpler.