JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Drawing shapes and text on canvas
Use the 2D context to fill and stroke rectangles, arcs and paths, place text on its baseline, and manage drawing state so each shape lands where you expect.
What you will learn
- Fill and stroke rectangles, arcs and multi-step paths with beginPath, fill and stroke
- Set fillStyle, strokeStyle, lineWidth and font before the call that paints
- Anchor text with fillText and move that anchor using textBaseline and textAlign
- Size the bitmap with canvas.width and height, not CSS, to avoid stretched output
Understanding Drawing shapes and text on canvas
A canvas element is a single DOM node that owns a bitmap. Every drawing call - fillRect, fill, fillText - writes colour values into that bitmap and then forgets what you asked for; there is no circle object left behind to move, restyle, or hang a click handler on. That is why interaction and animation on canvas are built around clearing a region and drawing it again from your own data, instead of editing something that already exists.
Everything is painted through the context's current state. fillStyle, strokeStyle, lineWidth, font, textBaseline and the transform are sticky properties: they persist until changed, and they are read at the instant fill(), stroke() or fillText() runs, not while you are building the path. Path building is a separate step - beginPath() discards the old path, moveTo/lineTo/arc/rect add to it, and fill() or stroke() paints it. ctx.save() and ctx.restore() push and pop that whole state block, so a helper can change colours and transforms without leaking them into the next shape.
Coordinates start at the top-left of the bitmap with y growing downward, and the bitmap size comes from canvas.width and canvas.height, not from CSS; when the two disagree the browser stretches the finished bitmap, which is why a CSS-resized canvas looks blurry and why click positions stop matching drawn positions. Strokes are centred on the path, so a 1-pixel line at an integer coordinate is split across two pixel columns and renders as two half-strength columns; adding 0.5 to the coordinate lines it up with the grid. Text follows the same logic of exact anchoring: fillText puts the string's baseline at the y you pass, so y = 0 pushes the glyphs above the visible area unless you set textBaseline to 'top'.
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 100;
document.body.append(canvas);
const ctx = canvas.getContext('2d');
// fillRect needs no path: it paints immediately
ctx.fillStyle = '#003366';
ctx.fillRect(0, 0, 200, 100);
// a circle: build a path, then paint it
ctx.fillStyle = 'orange';
ctx.beginPath();
ctx.arc(60, 50, 30, 0, Math.PI * 2);
ctx.fill();
// stroke draws only the edge; the interior is left alone
ctx.strokeStyle = 'white';
ctx.lineWidth = 4;
ctx.strokeRect(110, 20, 70, 60);
// text is anchored by its baseline unless you move the anchor
ctx.fillStyle = 'white';
ctx.font = '16px monospace';
ctx.textBaseline = 'top';
ctx.fillText('canvas', 8, 8);
const rgb = (x, y) => ctx.getImageData(x, y, 1, 1).data.slice(0, 3).join(',');
console.log('background rgb:', rgb(5, 95));
console.log('circle centre rgb:', rgb(60, 50));
console.log('inside the stroked box:', rgb(145, 50));
console.log('on the box edge:', rgb(110, 50));The 2D context is an immediate-mode painter that bakes pixels using whatever style state is set at the moment of the fill or stroke call, keeping no record of the shape itself.
Worked examples
Drawing state and save/restore
Shows that colours, line widths and the transform are one bundle of state you can push and pop.
const ctx = document.createElement('canvas').getContext('2d');
ctx.fillStyle = 'red';
ctx.lineWidth = 1;
ctx.save();
ctx.fillStyle = '#00ff00';
ctx.lineWidth = 8;
ctx.translate(50, 50);
ctx.fillRect(0, 0, 10, 10);
console.log('inside save:', ctx.fillStyle, ctx.lineWidth);
ctx.restore();
console.log('after restore:', ctx.fillStyle, ctx.lineWidth);
ctx.fillRect(0, 0, 10, 10);
console.log('pixel at 0,0:', ctx.getImageData(0, 0, 1, 1).data.join(','));
console.log('pixel at 55,55:', ctx.getImageData(55, 55, 1, 1).data.join(','));Example explained
Line 1ctx.save() copies the whole state - colours, lineWidth, font, transform - onto a stack.
Line 2translate(50, 50) moves the origin, so fillRect(0, 0, 10, 10) actually paints at 50,50.
Line 3restore() pops the state back, so the identical fillRect call afterwards lands at 0,0 in red.
Line 4Reading ctx.fillStyle returns the serialised colour ('#ff0000' for 'red'), a quick way to check the state you are in.
Where fillText actually puts the text
Proves that the y argument is the baseline by default, and that textBaseline re-anchors it.
const canvas = document.createElement('canvas');
canvas.width = 120;
canvas.height = 60;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'black';
ctx.font = 'bold 24px sans-serif';
ctx.fillText('HI', 10, 40);
const hasInk = (x, y, w, h) => {
const data = ctx.getImageData(x, y, w, h).data;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true;
}
return false;
};
console.log('ink above the baseline:', hasInk(0, 0, 100, 40));
console.log('ink below the baseline:', hasInk(0, 42, 100, 18));
ctx.textBaseline = 'top';
ctx.fillText('HI', 60, 40);
console.log('ink below y=42 now:', hasInk(60, 42, 60, 18));Example explained
Line 1ctx.font takes a CSS font shorthand, so it needs both a size and a family: 'bold 24px sans-serif'.
Line 2fillText('HI', 10, 40) treats y = 40 as the alphabetic baseline and paints the glyphs upward from it.
Line 3The scan reads every fourth byte, the alpha channel, so it reports where pixels were actually touched.
Line 4Switching textBaseline to 'top' re-anchors y to the top of the text box, so the second 'HI' paints downward from 40.
Important notes
The default state catches people out: fillStyle black, strokeStyle black, lineWidth 1, font '10px sans-serif', textBaseline 'alphabetic'.
Canvas text is just pixels - it cannot be selected, searched or read by a screen reader - so keep real labels in the DOM or give the canvas a text alternative.
Common mistakes
Sizing the canvas in CSS while leaving the bitmap at its 300x150 default: the browser stretches the small bitmap, so lines and text look blurry and mouse coordinates no longer match drawn coordinates.
Starting a new shape without beginPath(): the previous subpaths are still in the current path, so the next fill() or stroke() repaints them in the new colour and adds a stray line connecting the shapes.
Changing fillStyle or font after the fillText call, or writing ctx.font = '20px' with no family: the already-painted pixels keep the old colour, and the invalid font string is ignored so text stays at the default 10px sans-serif.
Try it yourself
Change, predict, then run
On a 300x150 canvas, draw one horizontal bar per value in [12, 30, 7, 22] using fillRect with the value scaled to pixels, then label each bar with its number using fillText and textBaseline = 'middle' so the digits sit centred on the bar.
Open the JavaScript workspaceCheck your understanding
You set ctx.lineWidth = 1, then moveTo(10, 0), lineTo(10, 50) and stroke(). The line appears two pixels wide and washed out. Why?
- lineWidth is in CSS pixels, so it gets multiplied by devicePixelRatio on a high-DPI screen.
- The path needs closePath() before stroke(), otherwise the renderer falls back to a soft two-pixel line.
- The stroke is centred on the path, so a 1-pixel wide line at x = 10 covers half of column 9 and half of column 10.
- fill() must run before stroke() so the renderer knows the exact edge of the shape.
Show answer
x = 10 is the boundary between pixel columns, and a stroke extends half its width either side of the path, so each column receives 50% coverage and is drawn at half opacity; moving the line to x = 10.5 makes it a crisp single column. The devicePixelRatio option is tempting, but the browser never rescales the bitmap for you - that only matters if you enlarge canvas.width yourself and call ctx.scale.