Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.
Chapter 17: Drawing on Canvas
Estimated time: ~35 minutes · Chapter type: Concept
What you'll learn
- Getting a drawing context and drawing basic shapes
- Paths, for anything more complex than a rectangle
- Transforms: translate, rotate, scale
- A simple animation loop
Getting a context
A <canvas> element is a blank, pixel-addressable rectangle. To draw on it, you ask for a rendering context — the object with all the actual drawing methods:
<canvas id="scene" width="400" height="300"></canvas>
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
Everything from here on is a method call on ctx. Unlike the DOM (Chapter 14), canvas has no persistent structure — once you draw a shape, the canvas only remembers the resulting pixels, not "a rectangle object" you can move or query later. If you want to change a scene, you clear it and redraw everything from your own data, every time.
Basic shapes
ctx.fillStyle = 'steelblue';
ctx.fillRect(20, 20, 150, 80); // filled rectangle: x, y, width, height
ctx.strokeStyle = 'crimson';
ctx.lineWidth = 3;
ctx.strokeRect(200, 20, 150, 80); // outlined rectangle only
ctx.clearRect(0, 0, 400, 300); // erase a region — commonly the whole canvas, before redrawing
Paths: anything that isn't a rectangle
For circles, lines, and custom shapes, you build a path — a sequence of points and curves — then fill or stroke it:
ctx.beginPath();
ctx.arc(100, 150, 40, 0, Math.PI * 2); // x, y, radius, startAngle, endAngle (radians)
ctx.fillStyle = 'seagreen';
ctx.fill();
ctx.beginPath();
ctx.moveTo(200, 150);
ctx.lineTo(260, 190);
ctx.lineTo(320, 130);
ctx.closePath(); // connects back to the starting point
ctx.strokeStyle = 'darkorange';
ctx.stroke();
beginPath() matters — forgetting it means your new shape gets appended to whatever path was drawn previously, producing confusing results.
Text
ctx.font = '24px sans-serif';
ctx.fillStyle = 'black';
ctx.fillText('Score: 42', 20, 250);
Transforms
Rather than computing rotated or scaled coordinates by hand, you can transform the canvas's own coordinate system and then draw normally — the transform applies to everything drawn afterward, until you reset it:
ctx.save(); // remember the current transform/style state
ctx.translate(200, 150); // move the origin to (200, 150)
ctx.rotate(Math.PI / 4); // rotate 45 degrees (radians, not degrees!)
ctx.fillRect(-25, -25, 50, 50); // drawn centered on the new, rotated origin
ctx.restore(); // pop back to the saved state, undoing translate + rotate
save()/restore() act like a stack: save() snapshots the current transform and style settings, and restore() pops back to the most recent snapshot — essential whenever you want a transform to apply to only one shape, not everything drawn after it.
A simple animation
Combine requestAnimationFrame (introduced in Chapter 16) with clearing and redrawing each frame to produce motion — here, a ball bouncing off the canvas edges:
const ball = { x: 50, y: 50, vx: 120, vy: 90, radius: 15 }; // position + velocity in px/sec
function update(deltaSeconds) {
ball.x += ball.vx * deltaSeconds;
ball.y += ball.vy * deltaSeconds;
if (ball.x < ball.radius || ball.x > canvas.width - ball.radius) ball.vx *= -1;
if (ball.y < ball.radius || ball.y > canvas.height - ball.radius) ball.vy *= -1;
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fillStyle = 'royalblue';
ctx.fill();
}
let lastTime = null;
function frame(time) {
if (lastTime !== null) {
update((time - lastTime) / 1000);
draw();
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Every frame follows the same three steps: clear the canvas, recompute state, redraw from that state — exactly the update/draw separation from the platform game project, just simplified to one object.
Key takeaways
getContext('2d')gives you the object with all drawing methods; canvas has no memory of shapes once drawn, only resulting pixels.- Use
fillRect/strokeRectfor rectangles; build abeginPath()/fill()/stroke()sequence for anything else. save()/restore()let you apply a transform (translate/rotate/scale) to just one shape without affecting everything drawn afterward.- Animation is: clear, update state based on elapsed time, redraw — repeated via
requestAnimationFrame.
Try it yourself
- Draw a simple smiley face using
arc()for the head and eyes, and a path for the mouth. - Modify the bouncing-ball example so the ball also changes color each time it bounces off a wall.
- Use
translate+rotateinside a loop to draw 12 rectangles arranged in a circle, like clock hour-marks.
Hints
- Three
arc()calls (head, two eyes) plus anarc()with a partial angle range for a curved mouth. - Set
ball.colorinside theifblocks that flipvx/vy, and useball.colorasfillStyleindraw(). - Inside a
forloop from 0 to 11,ctx.save(); ctx.translate(cx, cy); ctx.rotate(i * Math.PI / 6); ctx.fillRect(...); ctx.restore();for each mark.
Summary
Canvas gives you full pixel-level control, at the cost of having to manage all the state and redrawing yourself — a very different trade-off from the DOM's retained, queryable tree. Before our next project puts both DOM and canvas to work together, we need one more piece: getting data to and from a server.