Drawing with canvas
Draw shapes, pixels, and animations with the canvas 2D context, including paths, ImageData, high-DPI sizing, animation loops, and OffscreenCanvas.
- 01Draw with the 2D contextChoose the right canvas size, context settings, shapes, paths, text, and high-DPI scaling.
- 02Work with pixelsRead and write ImageData, apply filters, and explain why cross-origin images can taint a canvas.
- 03Animate responsiblyUse requestAnimationFrame, clear each frame, respect reduced motion, and feature-detect OffscreenCanvas.
Canvas as pixels, not page objects
A <canvas> is a rectangular drawing surface. JavaScript asks a drawing context to paint pixels into that surface: rectangles, lines, text, images, gradients, and raw pixel data. That makes canvas excellent for games, paint tools, visual effects, audio visualizers, and dense diagrams that redraw often.
The important mental model is immediate mode. When you call ctx.fillRect(20, 20, 80, 40), the browser changes pixels. It does not keep a rectangle object you can later select from the DOM and move. If you want to move that rectangle, you keep your own data model, clear the canvas, and draw the scene again.
Imagine painting a circle directly onto a wall. Once the paint dries, there is no separate “circle object” to pick up. The DOM and SVG are more like paper cutouts pinned to a board: the browser remembers each cutout, so you can grab one by its element and move it.
- In real life: Paint brushed onto a wall
- In JavaScript: Canvas pixels after
fillRect() - In real life: Paper cutouts pinned to a board
- In JavaScript: DOM or SVG elements the browser keeps
- In real life: To move paint, repaint the wall
- In JavaScript: To move canvas content, clear and redraw
- In real life: A sketchbook of planned shapes
- In JavaScript: Your own JavaScript scene data
Where the analogy stops: A real wall keeps paint for years. Canvas pixels disappear when you resize the drawing buffer, clear it, or redraw over them. Also, browsers may store pixels in GPU memory rather than a literal wall-like bitmap.
Canvas is a browser API for drawing into a bitmap. You control it with JavaScript, usually through the CanvasRenderingContext2D returned by canvas.getContext("2d").
This lesson stays on the 2D context. WebGL and WebGPU are separate APIs for GPU graphics; they use the same canvas element but a very different programming model.
The 2D context and the drawing buffer
INTERACTIVEStart with two objects: the canvas element and its 2D context. The element is part of the page. The context is the painter’s toolbox: it remembers settings such as fillStyle, strokeStyle, lineWidth, transforms, clipping, and the current path.
const canvas = document.querySelector("canvas");const ctx = canvas.getContext("2d"); ctx.fillStyle = "royalblue";ctx.fillRect(10, 10, 80, 40);ctx.clearRect(0, 0, canvas.width, canvas.height);Two sizes matter. The width and height attributes set the drawing buffer. If you omit them, the buffer is 300×150. CSS width and height only stretch that buffer on the page. On high-density screens, stretching a small buffer can look blurry, so many apps multiply the buffer by devicePixelRatio and scale the context back to CSS pixels.
function resizeCanvas(canvas, cssWidth, cssHeight, dpr) { canvas.style.width = cssWidth + "px"; canvas.style.height = cssHeight + "px"; canvas.width = Math.round(cssWidth * dpr); canvas.height = Math.round(cssHeight * dpr); const ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;} // On a DPR 2 screen: 600×300 backing pixels.On a device pixel ratio of 2, a 300×150 CSS canvas should usually have a 600×300 backing store. The drawing code can still use coordinates like 300 and 150 because setTransform(2, 0, 0, 2, 0, 0) maps CSS-sized coordinates to device pixels.
const canvas = document.querySelector("canvas");const ctx = canvas.getContext("2d");ctx.fillStyle = "#5b8def";ctx.strokeStyle = "#1f2937";ctx.lineWidth = 4;ctx.fillRect(24, 24, 96, 56);ctx.strokeRect(148, 24, 96, 56);ctx.beginPath();ctx.arc(92, 142, 36, 0, Math.PI * 2);ctx.fill();ctx.beginPath();ctx.moveTo(170, 164);ctx.lineTo(214, 106);ctx.lineTo(258, 164);ctx.closePath();ctx.stroke();ctx.fillText("Canvas", 24, 210);Drawings appear after hydration, because canvas pixels are a browser-only effect.
Try switching the high-DPI toggle. The code did not change the shapes; it changed the buffer math. That is the same idea you learned in Sizes, scrolling & coordinates: browser coordinates are often CSS pixels, while physical device pixels can be denser.
Paths and shapes: sketch first, ink later
STEP THROUGHCanvas has immediate drawing methods such as fillRect(), but paths are different. A path is a temporary sketch stored by the context. beginPath() starts a new sketch, moveTo() moves the pencil, lineTo() and arc() add segments, and fill() or stroke() finally paints pixels.
Think of a comic artist blocking out a panel in pencil. The sketch tells the artist where the final ink will go. Until it is inked, the reader sees nothing. Canvas paths behave the same way.
- In real life: Lift the pencil and move to a spot
- In JavaScript:
moveTo(x, y) - In real life: Sketch a line or curve lightly
- In JavaScript:
lineTo()andarc() - In real life: Connect back to the start
- In JavaScript:
closePath() - In real life: Ink the sketch
- In JavaScript:
stroke()orfill()
Where the analogy stops: A pencil mark is visible immediately. A canvas path is not visible until a painting command inks it.
A path is a sketch held by the 2D context. Step through the sketch, then ink it with fill or stroke.
script
const ctx = canvas.getContext("2d");ctx.moveTo(52, 124);ctx.lineTo(138, 42);ctx.arc(194, 124, 42, Math.PI, 0);ctx.closePath();ctx.fill();Angles in arc() are radians, not degrees. A full circle is Math.PI * 2. Half a circle is Math.PI. If degrees are friendlier, convert with degrees * Math.PI / 180 before passing the angle.
fill() uses the nonzero fill rule by default. You can pass "evenodd" for donut-like paths where overlapping rings cut holes. The rule only matters for filled paths with overlaps or nested subpaths; plain rectangles and circles look the same.
Images and pixels: ImageData is a mosaic
INTERACTIVECanvas can draw images, video frames, gradients, and generated art. Once pixels are in the buffer, getImageData() gives you an ImageData object. Its data property is a Uint8ClampedArray: a flat list of bytes, four per pixel: red, green, blue, alpha.
Imagine a mosaic where every tile has four tiny knobs: red, green, blue, and alpha. Filters are just rules for turning those knobs. Inverting a picture changes each color channel to 255 - value.
- In real life: One tiny tile
- In JavaScript: One pixel
- In real life: Four labels on the tile
- In JavaScript: Red, green, blue, alpha
- In real life: Numbers from 0 to 255
- In JavaScript: Byte values in
Uint8ClampedArray - In real life: Rebuild the mosaic
- In JavaScript:
putImageData(image, 0, 0)
Where the analogy stops: Real mosaic tiles have one color each. Canvas pixels store separate color channels, and alpha controls transparency.
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);for (let i = 0; i < image.data.length; i += 4) { const red = image.data[i]; const green = image.data[i + 1]; const blue = image.data[i + 2]; image.data[i] = 255 - red; image.data[i + 1] = 255 - green; image.data[i + 2] = 255 - blue;}ctx.putImageData(image, 0, 0);Click a filter to read ImageData, edit bytes, and put it back. Move over the canvas to inspect one RGBA tile.
getImageData throws SecurityError instead of returning pixels.The lab generates its picture in the page. If you draw an image from another origin without permission and then call getImageData(), the canvas becomes tainted and the browser throws SecurityError. That protects private pixels, such as images from a site where the visitor is logged in.
An animation loop: clear and redraw
INTERACTIVEA canvas animation is usually a loop: measure time, update your own model, clear the old pixels, draw the new frame, and schedule the next frame with requestAnimationFrame(). You saw the timing side in Timers and the Event loop; canvas adds the redraw step.
let x = 40;let velocity = 180;let lastTime = performance.now(); function frame(now) { const seconds = (now - lastTime) / 1000; lastTime = now; x = x + velocity * seconds; if (x > 260 || x < 40) velocity = -velocity; ctx.clearRect(0, 0, 300, 160); ctx.beginPath(); ctx.arc(x, 80, 22, 0, Math.PI * 2); ctx.fill(); requestAnimationFrame(frame);}Motion is available, but still starts only after you press Play. Press Step once or Play. This lesson never autostarts motion.
Notice that the lab does not autostart. Motion should be a choice, especially for readers who prefer reduced motion. The component reads prefers-reduced-motion after mount, offers a Step button, and clears its animation frame on unmount.
OffscreenCanvas: move drawing work to a worker
FEATURE DETECTOffscreenCanvas lets some drawing happen away from the main page thread, often inside a Worker. It is useful when drawing is heavy enough to compete with clicks, typing, or scrolling. Support is broad in modern browsers, but not universal, so feature detection is part of the API shape.
if ("OffscreenCanvas" in window) { const offscreen = canvas.transferControlToOffscreen(); const worker = new Worker(workerUrl); worker.postMessage({ canvas: offscreen }, [offscreen]);} else { showMessage("This browser does not support OffscreenCanvas yet.");}Press Try worker drawing to feature-detect OffscreenCanvas in this browser.
The demo creates a Worker from a Blob URL only after you click. Reset terminates the worker and revokes the URL. That cleanup matters: workers keep running until you stop them, and transferred canvases cannot be drawn from the main thread afterward.
Choose canvas, SVG, or DOM for the job
SORTERCanvas is powerful, but it is not a replacement for HTML. If users need to focus, select, search, copy, or hear content with assistive technology, real DOM elements usually win. SVG sits between them: graphical like canvas, but retained as elements.
| Thing | Canvas | SVG | DOM |
|---|---|---|---|
| Memory model | Pixels in a drawing buffer | Elements for each shape | Elements for document UI |
| Best at | Games, paint tools, dense visualizations | Diagrams and charts with real shapes | Forms, text, app layout |
| Hit testing | You write math or use a hidden model | Browser can target each shape | Browser targets elements |
| Accessibility | You provide separate labels or controls | Shapes can be labelled | Built-in for semantic elements |
- A brush app with thousands of strokes
- A line chart where each point needs a tooltip and keyboard focus
- A checkout form with inputs, labels, and validation messages
- A fast sprite game redrawn 60 times a second
- A scalable logo that should stay sharp at any size
- A paragraph with links, selection, and browser find support
Sort each real task into canvas, SVG, or DOM.
Where you’ll use this in real projects
A practical canvas component normally keeps a plain JavaScript model beside the pixels. For a diagram, that might be an array of nodes and edges. For a game, it might be sprites with positions and velocities. For a signature pad, it might be pointer strokes. The canvas is the output, not the source of truth.
const circles = [ { x: 40, y: 50, radius: 18, color: "tomato" }, { x: 120, y: 80, radius: 28, color: "royalblue" },]; function drawScene(ctx) { ctx.clearRect(0, 0, 300, 150); for (const circle of circles) { ctx.fillStyle = circle.color; ctx.beginPath(); ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); ctx.fill(); }}Hit testing uses the same model. Canvas will not tell you which circle was clicked; you compare the pointer coordinate with your stored shapes. A rectangle check is a bounds comparison, and a circle check is distance from the center.
Common misconceptions
- “Canvas shapes are DOM nodes.” They are pixels. Keep your own model if you need to move, delete, label, or hit-test drawn things.
- “CSS size and canvas size are the same.” The attributes set the drawing buffer. CSS only stretches the result.
- “Paths draw as soon as you call
lineTo().” Path commands sketch.stroke()orfill()inks. - “Pixel filters can read any image.” Cross-origin images without permission taint the canvas, and
getImageData()throwsSecurityError. - “Canvas animation means never clearing.” Most loops clear and redraw each frame. Trails are an effect you choose deliberately.
- “OffscreenCanvas is automatic performance.” It can move drawing work, but you still pay for messages, data copies, fallbacks, and cleanup.
Practice exercises
5 EXERCISESBefore running it, predict the console output.
const degrees = 180;
const radians = degrees * Math.PI / 180;
console.log(radians === Math.PI);const degrees = 180;
const radians = degrees * Math.PI / 180;
console.log(radians === Math.PI);The formula converts degrees to radians. 180 * Math.PI / 180 is exactly Math.PI, so the comparison prints true.
Work out the backing store size for a 300×150 canvas on a DPR 2 screen.
function backing(cssWidth, cssHeight, dpr) {
return [cssWidth * dpr, cssHeight * dpr].join("x");
}
console.log(backing(300, 150, 2));function backing(cssWidth, cssHeight, dpr) {
return [cssWidth * dpr, cssHeight * dpr].join("x");
}
console.log(backing(300, 150, 2));A 300×150 CSS canvas on a DPR 2 screen uses a 600×300 drawing buffer, then scales the context so drawing code can still use CSS-sized coordinates.
Predict the exact comma-separated RGBA values.
const pixel = [10, 20, 30, 255];
console.log([255 - pixel[0], 255 - pixel[1], 255 - pixel[2], pixel[3]].join(","));const pixel = [10, 20, 30, 255];
console.log([255 - pixel[0], 255 - pixel[1], 255 - pixel[2], pixel[3]].join(","));The red, green, and blue channels become 245, 235, and 225. Alpha stays 255, so the logged value is 245,235,225,255.
The code runs without an error, but the canvas stays blank. Explain the missing line and add it.
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(120, 20);
ctx.lineTo(120, 80);
// Why is nothing visible?ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(120, 20);
ctx.lineTo(120, 80);
ctx.stroke();lineTo() only adds segments to the current path. stroke() paints the outline; fill() would paint the inside.
Write the hit-test logic you would use after converting a pointer event to canvas coordinates.
const buttons = [
{ x: 20, y: 20, width: 90, height: 36, label: "Save" },
{ x: 120, y: 20, width: 90, height: 36, label: "Cancel" },
];
function hitButton(point) {
// return the clicked button, or undefined
}function hitButton(point) {
return buttons.find((button) =>
point.x >= button.x &&
point.x <= button.x + button.width &&
point.y >= button.y &&
point.y <= button.y + button.height
);
}The array is the retained model. The canvas only shows pixels, so pointer logic compares coordinates against the saved rectangles.
Check your understanding
7 QUESTIONSQuestion 1 of 7What is the canvas element’s default drawing buffer size when you omit width and height attributes?
Choose an answer to see the explanation.
Question 2 of 7What does this angle code print?
Read the code, then predictconsole.log(Math.PI === 180 * Math.PI / 180);Choose an answer to see the explanation.
Question 3 of 7Which statement about paths is accurate?
Choose an answer to see the explanation.
Question 4 of 7Which type stores
ImageData.datavalues?Choose an answer to see the explanation.
Question 5 of 7What can make
getImageDatathrowSecurityError?Choose an answer to see the explanation.
Question 6 of 7What is the usual animation loop pattern?
Choose an answer to see the explanation.
Question 7 of 7What is the safest way to use OffscreenCanvas?
Choose an answer to see the explanation.
Key takeaways
- Canvas is immediate-mode drawing: calls change pixels, not DOM objects.
- The 2D context stores drawing settings, the current path, and transforms;
save()andrestore()let you put that tray of settings aside and pick it back up. - Width and height attributes set the buffer. CSS size only stretches it, so high-DPI screens often need a larger backing store.
- Paths are sketches until
fill()orstroke()paints them. Angles are radians. ImageData.datais aUint8ClampedArrayof RGBA bytes. Tainted canvases cannot be read.- Animations clear and redraw in
requestAnimationFrame(); OffscreenCanvas is useful but must be feature-detected.
Canvas is JavaScript-controlled pixel drawing: keep your own scene model, then paint the current frame into a bitmap.
Up next: Files & Blobs.