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 contextSet the canvas size, colors, shapes, paths, and text, and stop drawings from looking blurry on sharp screens.
- 02Work with pixelsRead and change pixel numbers with
ImageData, and explain why a picture from another site can block reading pixels. - 03Animate with careClear and draw each frame with
requestAnimationFrame(), respect people who want less motion, and check forOffscreenCanvasbefore using it.
Canvas is pixels, not page elements
A <canvas> is a rectangle on the page that you draw on with JavaScript. The rectangle is a grid of tiny colored dots called pixels. You can paint rectangles, lines, text, pictures, and color fades into it. You can also change single pixels.
Canvas is a good fit for things that redraw often:
- games
- paint and drawing apps
- visual effects and music visualizers
- busy charts with many points
The most important idea is this: a drawing call only changes pixels. When you call ctx.fillRect(20, 20, 80, 40), the browser colors a block of pixels. It does not keep a rectangle object that you can find later and move.
To move the rectangle, you keep its position in your own JavaScript data. Then you clear the canvas and draw everything again at the new position. This style is called immediate mode: each call paints right away and is then forgotten.
fillRect() only colors pixels, so nothing is left to pick up later. A <div> on the page stays an element that you can find and move.Imagine you paint a circle on a wall. When the paint dries, there is no circle you can lift off the wall. Now imagine paper cutouts pinned to a board. Each cutout is its own thing, so you can unpin one and move it.
Canvas is the painted wall. Normal page elements are the paper cutouts.
- 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: Page elements (DOM or SVG) that the browser keeps
- In real life: To move the paint, paint the wall again
- In JavaScript: To move canvas content, clear and draw again
- In real life: A notebook that lists the shapes you plan
- In JavaScript: Your own JavaScript data about the shapes
Canvas is a browser feature for drawing pixels with JavaScript. You draw through an object called the 2D context. You get it with canvas.getContext("2d"). Its full name is CanvasRenderingContext2D.
This lesson only covers the 2D context. WebGL and WebGPU are separate tools for fast graphics drawn by the graphics card, such as 3D scenes. They draw on the same <canvas> element, but you write very different code for them.
The 2D context and the drawing buffer
INTERACTIVEYou work with two objects:
- The canvas element. It is the rectangle on the page.
- The 2D context, usually saved in a variable called
ctx. It is like a painter's toolbox. It has the drawing commands, and it remembers your settings.
The settings include the fill color (fillStyle), the outline color (strokeStyle), and the line thickness (lineWidth). The context also remembers moves and zooms (called transforms), a clipping area that limits where paint can land, and the current path. You'll meet paths in the next section.
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);Line 1 finds the canvas element. Line 2 asks it for the 2D context. Line 4 sets the fill color to blue. Line 5 paints a blue rectangle: 10 pixels from the left, 10 from the top, 80 wide, and 40 tall. Line 6 wipes the whole canvas clear again.
A canvas has two sizes, and they are easy to mix up:
- The
widthandheightattributes set the drawing buffer: how many pixels the canvas really has. If you leave them out, the buffer is 300×150. - The CSS width and height only set how big the canvas looks on the page. CSS stretches the buffer to fit.
Many phones and laptops have sharp screens that pack 2 or 3 real screen dots into one CSS pixel. The browser tells you this number in devicePixelRatio. On those screens, a small stretched buffer looks blurry. The fix is to make the buffer bigger by that number, then scale the drawing back down 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;} // With a device pixel ratio of 2: a 600×300 buffer.Lines 2 and 3 set the size the canvas looks on the page. Lines 4 and 5 make the buffer bigger by the device pixel ratio. Line 7 scales the drawing, so your code can keep using CSS-sized numbers.
So with a device pixel ratio of 2, a canvas that is 300×150 on the page should have a 600×300 buffer. Your drawing code can still use numbers like 300 and 150, because setTransform(2, 0, 0, 2, 0, 0) doubles every number you draw with.
ctx.fillStyle = "tomato";ctx.save();ctx.fillStyle = "royalblue";ctx.fillRect(10, 10, 30, 30);ctx.restore();ctx.fillRect(50, 10, 30, 30);Line 1 sets the fill color to tomato red. Line 2, save(), puts all current settings aside. Line 3 changes the color to blue, and line 4 paints a blue square. Line 5, restore(), brings back the saved settings, so the color is tomato red again. Line 6 paints a red square.
save() keeps colors, line width, transforms, and the clipping area. restore() does not erase pixels you already painted. Put the pair around a short change, so the drawing after it doesn't get the changed setting by accident.
ctx.beginPath();ctx.moveTo(20, 30);ctx.lineTo(120, 30);ctx.stroke(); ctx.beginPath();ctx.moveTo(20, 70);ctx.lineTo(120, 70);ctx.stroke();Lines 1 to 4 draw one line. Lines 6 to 9 draw a second line lower down. Each part starts with beginPath(). It throws away the old path that the context remembers. It does not erase any pixels.
If you skip beginPath() on line 6, the old line stays in the path. Then the second stroke() paints the first line again too.
ctx.font = "16px sans-serif";const label = "Canvas";const width = ctx.measureText(label).width;ctx.fillText(label, 10, 30);console.log(Math.round(width) > 0);Line 1 picks the font. Line 3 measures how wide the word will be, without drawing it. Line 4 paints the word at x = 10. The letters sit on an invisible line at y = 30, called the baseline. Line 5 prints true, because the width is more than 0.
Knowing the width first lets you center a label, or cut it short, before you paint it.
canvas.width = 300;canvas.height = 150;canvas.style.width = "600px";canvas.style.height = "300px";console.log(canvas.width + "x" + canvas.height);Lines 1 and 2 set the real drawing buffer to 300×150. Lines 3 and 4 only make it look 600×300 on the page. So line 5 still prints 300x150. To get more pixels to draw on, change the attributes, not the CSS.
300x150.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);The drawing appears once the page has loaded, because only the browser can paint canvas pixels.
Try the sharp/blurry button. The shapes stay the same; only the buffer size changes. You saw the same idea in Sizes, scrolling & coordinates: the browser usually measures in CSS pixels, but the screen may have more real dots than that.
Paths and shapes: sketch first, then ink
STEP THROUGHSome canvas commands paint right away, like fillRect(). Paths work differently. A path is a sketch that the context keeps in memory. Nothing shows until you paint it. You build it step by step:
beginPath()starts a new, empty sketch.moveTo(x, y)lifts the pencil and puts it at a point.lineTo(x, y)adds a straight line.arc()adds a curve.fill()paints the inside, orstroke()paints the outline. Only now do pixels change.
A comic artist first draws a panel lightly in pencil. The pencil lines show where the ink will go. Readers only see the panel after the artist goes over it in ink.
- 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: Join the end back to the start
- In JavaScript:
closePath() - In real life: Go over the sketch in ink
- In JavaScript:
stroke()orfill()
fill() or stroke(), paints it, and only then do pixels change.A path is a sketch that the 2D context keeps. Step through the sketch, then paint 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();arc() measures angles in radians, not degrees. A full circle is Math.PI * 2. Half a circle is Math.PI. If you think in degrees, change them to radians first with degrees * Math.PI / 180.
A fill rule decides which areas count as "inside" a path. fill() uses the nonzero rule unless you say otherwise. Pass "evenodd" to cut a hole where one ring sits inside another, like a donut.
The rule only matters for filled paths whose parts overlap or sit inside each other. Plain rectangles and circles look the same with either rule.
Images and pixels: ImageData is a mosaic
INTERACTIVECanvas can draw pictures, frames from a video, color fades, and shapes made by code. Once pixels are on the canvas, getImageData() copies them into an ImageData object.
Its data property holds the pixels as one long list of numbers. Each pixel uses four numbers in a row: red, green, blue, and alpha. Alpha says how see-through the pixel is. Every number is from 0 to 255. The list type is Uint8ClampedArray: a list that can only hold whole numbers from 0 to 255.
Imagine a mosaic where every tile has four tiny knobs: red, green, blue, and alpha. A filter is a rule for turning the knobs on every tile.
- In real life: One tiny tile
- In JavaScript: One pixel
- In real life: Four knobs on the tile
- In JavaScript: Red, green, blue, alpha
- In real life: Each knob turns from 0 to 255
- In JavaScript: The numbers in
Uint8ClampedArray - In real life: Put the mosaic back on the wall
- In JavaScript:
putImageData(image, 0, 0)
For example, to invert a picture (swap each color for its opposite), you set each color number to 255 - value. Alpha stays the same.
255 - value and keeps alpha as it was.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. It reads the pixels, changes their numbers, and puts them back. Move over the canvas to read the four numbers of one pixel.
getImageData would throw a SecurityError instead.The lab draws its own picture with code, so reading its pixels is allowed. Pictures from another website are different. An origin is the address of a website, like https://example.com. If you draw a picture from another origin without its permission, the canvas becomes tainted: marked as unsafe to read. After that, getImageData() throws a SecurityError.
This rule protects private pictures. For example, a page should not be able to read the pixels of a photo that only shows because the visitor is logged in to another site.
const image = new Image();
image.onload = () => {
ctx.drawImage(image, 0, 0);
console.log("drawn after load");
};
image.src = "avatar.png";Line 1 makes an empty image. Line 6 sets src, the picture's address, which starts the download. The pixels are not ready yet at that moment. Lines 2 to 5 set up onload, which runs when the download finishes. Only then does line 3 draw the picture with drawImage().
A picture from another origin also needs that site's permission before you can read its pixels. The site gives permission with a special response header, part of a system called CORS.
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
ctx.drawImage(image, 0, 0);
try {
ctx.getImageData(0, 0, 1, 1);
} catch (error) {
console.log(error.name);
}
};
image.src = "https://example.com/photo.png";Line 2 asks for permission by setting crossOrigin. It must come before src on line 11. If the other site does not allow it, the picture won't load, so onload never runs. Without line 2, the picture would still draw, but the canvas would be tainted. Then line 6 would throw, and line 8 would print SecurityError.
A tainted canvas still shows on the page. Only reading its pixels from code is blocked. Your page cannot give itself permission; only the other site can.
An animation loop: clear and draw again
INTERACTIVEA canvas animation is usually a loop. Each turn of the loop is one frame, and it does five things:
- Measure how much time has passed.
- Update your own data, like a ball's position.
- Clear the old pixels.
- Draw the new frame.
- Ask for the next frame with
requestAnimationFrame().
requestAnimationFrame(frame) asks the browser to call frame just before it next paints the screen. You saw timing in Timers and the Event loop. Canvas adds the clear-and-draw-again 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 only starts when you press Play. Press Step one frame or Play. The ball never starts moving on its own.
The lab does not start moving on its own. Some people feel unwell when things move on screen, so their computer asks pages to use less motion. A page can check this with the prefers-reduced-motion setting.
The lab checks that setting after the page loads. It gives you a Step button to move one frame at a time. It also cancels its next frame when the lab leaves the page.
OffscreenCanvas: draw in a worker
FEATURE DETECTNormally, all your page code runs on one track, called the main thread. Clicks, typing, and scrolling are handled there too. A worker is a second track that runs code at the same time.
OffscreenCanvas lets a worker do the drawing. That helps when drawing is heavy enough to slow down clicks, typing, or scrolling. Most new browsers support it, but not all. So you first check that it exists. This check is called feature detection.
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 check whether this browser has OffscreenCanvas.
Line 1 checks that the browser has OffscreenCanvas. Line 2 hands control of the canvas over to an OffscreenCanvas. Line 3 starts a worker. Line 4 sends the canvas to the worker. Line 6 shows a message when the browser has no support.
The demo only starts a worker when you click. It builds the worker's code from a Blob URL, a short-lived address for text made in the page. Reset stops the worker with terminate() and frees the Blob URL.
This cleanup matters, because a worker keeps running until you stop it. Also, once a canvas is handed to a worker, the main page can no longer draw on it.
Choose canvas, SVG, or normal page elements
SORTERCanvas is powerful, but it does not replace HTML. People need to click into things, select text, search the page, copy, and use screen readers (tools that read the page out loud). For that, normal page elements, the DOM, usually work best.
SVG sits in between. It draws shapes like canvas, but the browser keeps each shape as an element.
<canvas width="300" height="150" aria-label="Sales by drink">
<table>
<caption>Sales by drink</caption>
<tr><th>Tea</th><td>3</td></tr>
<tr><th>Bread</th><td>5</td></tr>
</table>
</canvas>Line 1 gives the canvas a name with aria-label. Screen readers say that name. Lines 2 to 6 put a real table inside the canvas. People can read the table if the canvas can't be drawn.
For charts, it is best to also show the data in a real table next to the picture. Then the chart is not the only way to read the numbers.
The table compares the three tools. Read each row as one question, and each column as one tool's answer.
| Question | Canvas | SVG | DOM (page elements) |
|---|---|---|---|
| What does the browser keep? | Only the pixels in the drawing buffer. | One element for each shape. | One element for each part of the page. |
| What is it best at? | Games, paint apps, and busy charts. | Diagrams and charts made of real shapes. | Forms, text, and page layout. |
| How do you find what was clicked? | You compare the click with your own data, using math. | The browser tells you which shape was clicked. | The browser tells you which element was clicked. |
| How do screen readers understand it? | You add a separate label or real controls. | Each shape can have its own label. | Most elements, like buttons and headings, work as they are. |
- 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 game with moving characters, drawn 60 times a second
- A logo that should stay sharp at any size
- A paragraph with links that people can select and search
Sort each real task into canvas, SVG, or DOM.
Where you'll use this in real projects
A real canvas feature keeps plain JavaScript data next to the pixels. For example:
- A diagram keeps an array of boxes and the lines between them.
- A game keeps each character's position and speed.
- A signature pad keeps the list of pen strokes.
The data is the real record of what is on screen. The canvas only shows a picture of it.
const sales = [3, 5, 2];const labels = ["Tea", "Bread", "Rice"];const ctx = canvas.getContext("2d");ctx.fillStyle = "royalblue";sales.forEach((amount, index) => { const x = 20 + index * 70; const y = 140 - amount * 20; ctx.fillRect(x, y, 42, amount * 20); ctx.fillStyle = "black"; ctx.fillText(labels[index], x, 158); ctx.fillStyle = "royalblue";});Line 1 holds three numbers. Line 5 loops over them. Each time, line 6 picks the bar's left edge, and line 7 picks its top edge. Line 8 paints the bar. Lines 9 and 10 write its name underneath in black.
Why does line 7 subtract? On a canvas, y grows downward: 0 is the top. To make a bar stand on a base line at y = 140, its top must be higher up, which means a smaller y.
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(); }}Line 1 keeps the two circles as plain objects. Line 7 clears the canvas. Line 8 loops over the circles, and line 11 draws each one. To move a circle, change its x in the array and call drawScene() again.
Finding which shape was clicked is called hit testing. It uses the same array. Canvas can't tell you which circle was clicked. You compare the click's position with your saved shapes instead:
- For a rectangle, check that the point is between its left and right edges, and between its top and bottom.
- For a circle, check that the point is no farther from the center than the radius.
Common misconceptions
- "Canvas shapes are page elements." They are only pixels. Keep your own data if you need to move, delete, label, or click on a shape.
- "The CSS size is the canvas size." The
widthandheightattributes set the drawing buffer. CSS only stretches it. - "
lineTo()draws a line right away." Path commands only sketch.stroke()orfill()paints. - "Pixel filters can read any picture." A picture from another site without permission taints the canvas. Then
getImageData()throws aSecurityError. - "Canvas animation never clears." Most loops clear and draw again every frame. A trail behind a moving thing only appears if you choose to skip the clear.
- "OffscreenCanvas makes everything fast." It can move drawing to a worker. You still pay for sending messages, copying data, a fallback, and cleanup.
Practice exercises
5 EXERCISESBefore you run it, predict what the console prints.
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 turns degrees into radians. 180 * Math.PI / 180 is exactly Math.PI, so the comparison prints true.
Work out the buffer size for a canvas that is 300×150 on the page, on a screen with a device pixel ratio of 2.
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 canvas that is 300×150 on the page, on a screen with a device pixel ratio of 2, needs a 600×300 drawing buffer. Then you scale the context, so your drawing code can still use the CSS sizes.
Predict the four numbers it prints, separated by commas.
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(","));Red, green, and blue become 245, 235, and 225. Alpha stays 255, so the program prints 245,235,225,255.
The code runs without an error, but the canvas stays blank. Explain which line is missing, 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 lines to the sketch. stroke() paints the outline. fill() would paint the inside instead.
Two buttons are drawn on a canvas. Write hitButton() so it returns the button under a click point. Assume the point is already in canvas pixels.
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 buttons array is your own record of the shapes. The canvas only shows pixels, so the click code compares the point with each saved rectangle. find() returns the first button that holds the point.
Check your understanding
7 QUESTIONSQuestion 1 of 7A canvas has no
widthorheightattributes. How big is its drawing buffer?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 sentence about paths is true?
Choose an answer to see the explanation.
Question 4 of 7What kind of list is
ImageData.data?Choose an answer to see the explanation.
Question 5 of 7What can make
getImageDatathrow aSecurityError?Choose an answer to see the explanation.
Question 6 of 7What does a usual canvas animation loop do each frame?
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 drawing calls only change pixels. They don't make page elements you can move later.
- The 2D context holds the drawing commands, your settings, and the current path.
save()andrestore()put settings aside and bring them back. - The
widthandheightattributes set the drawing buffer. CSS only stretches it, so sharp screens need a bigger buffer. - A path is a sketch until
fill()orstroke()paints it. Angles are in radians. ImageData.dataholds four numbers per pixel: red, green, blue, and alpha. You can't read the pixels of a tainted canvas.- Animations clear and draw again inside
requestAnimationFrame(). Check thatOffscreenCanvasexists before you use it.
Canvas is pixel drawing with JavaScript: keep your own data about the shapes, and paint each frame from that data.
Up next: Files & Blobs.