Transformations
Every drawing command on a 2D canvas is filtered through a transformation matrix before pixels land on screen. By default that matrix is the identity matrix, so coordinates map one-to-one. Once you call translate, rotate, or scale, the matrix changes and every subsequent path is moved, turned, or resized automatically. Mastering transforms lets you draw shapes in convenient local coordinates and then place them anywhere, which is the foundation of sprites, charts, and animation.
The transformation matrix
Internally the canvas keeps a 2D affine matrix with six values: a, b, c, d, e, f. Each point (x, y) you pass to a drawing call is mapped to a new point:
x' = a * x + c * y + e
y' = b * x + d * y + f
Here a and d control horizontal and vertical scaling, b and c control shearing/rotation, and e and f are translation. You rarely set these by hand — the helper methods translate, rotate, and scale multiply the current matrix for you. Transforms are cumulative: calling translate(10, 0) twice moves the origin by 20 pixels total.
translate, rotate, and scale
translate(x, y) shifts the origin of the coordinate system. rotate(angle) rotates around the current origin (angles are in radians, clockwise). scale(sx, sy) multiplies all coordinates, stretching or shrinking shapes — and importantly, line widths and fonts too.
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.fillStyle = "teal";
ctx.translate(100, 60); // move origin to (100, 60)
ctx.rotate(Math.PI / 6); // rotate 30 degrees
ctx.scale(2, 1); // stretch horizontally
// Drawn in transformed space; (0,0) is now at (100,60)
ctx.fillRect(0, 0, 40, 20);
Because the order of operations matters, think of transforms as moving the paper, not the pen. The last transform applied is the first one the coordinates “see.”
| Method | Signature | Effect |
|---|---|---|
translate | translate(x, y) | Moves the origin by (x, y) |
rotate | rotate(angle) | Rotates around the origin, radians, clockwise |
scale | scale(sx, sy) | Scales coordinates; negative values flip/mirror |
transform | transform(a, b, c, d, e, f) | Multiplies the current matrix by a custom one |
setTransform | setTransform(a, b, c, d, e, f) | Replaces the matrix outright |
resetTransform | resetTransform() | Restores the identity matrix |
Tip: Convert degrees to radians with
radians = degrees * Math.PI / 180. Passing degrees directly is the single most common transform bug.
transform vs setTransform
transform() concatenates a matrix onto whatever is already in effect, so it stacks with prior transforms. setTransform() discards the current matrix and installs a fresh one, which is handy for resetting to a known state without touching the save/restore stack.
// Equivalent ways to scale by 2 and shift right by 50:
ctx.transform(2, 0, 0, 2, 50, 0); // multiply onto current matrix
// Wipe everything and set an absolute matrix:
ctx.setTransform(1, 0, 0, 1, 0, 0); // identity (same as resetTransform)
Modern browsers also accept a DOMMatrix argument: ctx.setTransform(new DOMMatrix()). Reading the current matrix back is possible with ctx.getTransform(), which returns a DOMMatrix you can inspect or invert.
Rotating around a point
rotate() always pivots around the current origin (0, 0). To spin a shape around its own center — or any arbitrary point — use the translate-rotate-translate trick: move the origin to the pivot, rotate, then move back (or draw centered).
function rotateAround(ctx, cx, cy, angle, draw) {
ctx.save();
ctx.translate(cx, cy); // origin -> pivot
ctx.rotate(angle); // spin around pivot
ctx.translate(-cx, -cy); // origin -> back
draw();
ctx.restore();
}
rotateAround(ctx, 80, 80, Math.PI / 4, () => {
ctx.fillStyle = "crimson";
ctx.fillRect(50, 50, 60, 60); // rotated 45deg around (80,80)
});
Wrapping the work in save()/restore() keeps each rotation isolated so transforms don’t leak into later drawing. The interactive demo below uses exactly this pattern in an animation loop.
<canvas id="c" width="320" height="200" style="background:#0d1117"></canvas>
<script>
const ctx = document.getElementById("c").getContext("2d");
let angle = 0;
function frame() {
ctx.clearRect(0, 0, 320, 200);
ctx.save();
ctx.translate(160, 100); // move origin to canvas center
ctx.rotate(angle); // rotate around that center
ctx.fillStyle = "#3fb950";
ctx.fillRect(-40, -40, 80, 80); // centered square spins in place
ctx.restore();
angle += 0.02;
requestAnimationFrame(frame);
}
frame();
</script>
resetTransform
After a series of transforms, the matrix is no longer the identity. Calling resetTransform() is the cleanest way to return to absolute pixel coordinates without remembering whether you balanced every save() with a restore().
ctx.translate(100, 100);
ctx.scale(3, 3);
ctx.fillRect(0, 0, 10, 10); // drawn large and offset
ctx.resetTransform(); // back to identity
ctx.fillRect(0, 0, 10, 10); // drawn at true (0,0), 10x10
The second example shows a flip-and-mirror layout that combines a negative scale with setTransform resets between draws.
<canvas id="mirror" width="320" height="160" style="background:#161b22"></canvas>
<script>
const ctx = document.getElementById("mirror").getContext("2d");
const drawArrow = () => {
ctx.fillStyle = "#58a6ff";
ctx.beginPath();
ctx.moveTo(20, 80);
ctx.lineTo(120, 60);
ctx.lineTo(120, 100);
ctx.closePath();
ctx.fill();
};
ctx.setTransform(1, 0, 0, 1, 0, 0); // identity
drawArrow(); // normal arrow
ctx.setTransform(-1, 0, 0, 1, 320, 0); // mirror across vertical center
drawArrow(); // flipped copy on the right
</script>
Output:
A blue arrow points right on the left half of the canvas,
and a mirrored blue arrow points left on the right half.
Best Practices
- Always wrap transforms in
save()/restore()so they don’t bleed into later draws. - Remember angles are in radians and rotation is clockwise; convert from degrees explicitly.
- Apply transforms in logical order — translate to the pivot first, then rotate or scale.
- Use
setTransformorresetTransformto return to a known matrix instead of guessing save/restore balance. - Be aware that
scalealso scales line widths, shadows, and text; account for it or reset before stroking. - For animation, reset and re-apply transforms each frame rather than accumulating them indefinitely.
- Prefer the
DOMMatrixoverloads ofsetTransform/getTransformwhen you need to compose or invert matrices programmatically.