Skip to main content
Adapted, not copied

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/strokeRect for rectangles; build a beginPath()/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

  1. Draw a simple smiley face using arc() for the head and eyes, and a path for the mouth.
  2. Modify the bouncing-ball example so the ball also changes color each time it bounces off a wall.
  3. Use translate + rotate inside a loop to draw 12 rectangles arranged in a circle, like clock hour-marks.
Hints
  1. Three arc() calls (head, two eyes) plus an arc() with a partial angle range for a curved mouth.
  2. Set ball.color inside the if blocks that flip vx/vy, and use ball.color as fillStyle in draw().
  3. Inside a for loop 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.

 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →