Drawing with JavaScript Canvas: 2D Context, Animation and Pixel Data
We draw shapes, paths, gradients and text with the canvas 2D context, process images pixel by pixel, build an animation loop with requestAnimationFrame and fix blurriness on retina screens.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
We create a canvas element, check its size, then widen it with CSS and check again:
const canvas = document.createElement("canvas");
console.log(canvas.width, canvas.height);
canvas.style.width = "600px";
console.log(canvas.width);
What does this code print to the console? What is the width of a canvas we never gave a size, and what does canvas.width become after we make it 600 pixels with CSS? Make a guess before you keep reading. We'll give the answer when we talk about why a canvas sometimes looks blurry.
Canvas and Brush: How Canvas Works
Until now, whenever we wanted to add something to the page, we put an element into the DOM. We can move a button later, change its color, listen for its clicks; the browser remembers every element. Canvas works completely differently. The DOM is like a magnetic board: you can move the pieces whenever you like. A canvas is like a painted wall: once the paint dries, that circle is no longer an "object", just colored pixels. If you want to move the circle, you wipe the wall and paint it again.
This model is called immediate mode, and it's also where canvas gets its power. Keeping thousands of shapes as DOM elements is expensive; on a canvas each one is just a few painted pixels. That's why games, charts and image editors use canvas.
To draw on a canvas, you first ask it for a context, that is, a brush. Save the file below and open it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Canvas</title>
<style>canvas { border: 1px solid #ccc; }</style>
</head>
<body>
<canvas id="canvas" width="400" height="250">Your browser doesn't support canvas.</canvas>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#2563eb";
ctx.fillRect(20, 20, 120, 80);
ctx.strokeStyle = "crimson";
ctx.lineWidth = 4;
ctx.strokeRect(170, 20, 120, 80);
ctx.clearRect(50, 40, 40, 40);
</script>
</body>
</html>
The coordinate system starts at the top-left corner and y grows downward, the opposite of the graphs you saw at school. fillRect(x, y, width, height) draws a filled rectangle, strokeRect draws only its outline, and clearRect wipes that area and makes it transparent. That's why there's a hole in the middle of the blue rectangle.
Notice that we don't pass the color to the drawing function as an argument. fillStyle, strokeStyle and lineWidth are the context's state; like the brush in a painter's hand, they stay the same until you change them. If you want to make a temporary change and then go back, ctx.save() puts the current state aside and ctx.restore() brings it back. The text inside the <canvas> tag is fallback content shown in environments that can't display a canvas.
Paths: Lines, Triangles and Circles
Everything other than rectangles is drawn with a path. A path is like a sketch you draw without lifting your pencil; nothing appears on screen until you call fill() or stroke(). Add these lines to the script in the file above, below the ctx definition:
// Triangle
ctx.beginPath();
ctx.moveTo(60, 230);
ctx.lineTo(140, 230);
ctx.lineTo(100, 160);
ctx.closePath();
ctx.fillStyle = "seagreen";
ctx.fill();
// Circle
ctx.beginPath();
ctx.arc(250, 195, 35, 0, Math.PI * 2);
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.stroke();
beginPath() starts a new sketch. moveTo lifts the pencil and moves it to a point, lineTo draws a line from there, and closePath connects the last point back to the first. arc(x, y, radius, startAngle, endAngle) draws an arc with the given center and radius. The angles are in radians, not degrees: a full turn is Math.PI * 2, half a turn is Math.PI. If you want a semicircle, setting the end angle to Math.PI is enough.
Color, Gradients and Text
You can give fillStyle any color format you use in CSS: "tomato", "#ff6347", "rgb(255 99 71 / 50%)". Instead of a color you can also give it a gradient. Add this to the same script and try it:
const gradient = ctx.createLinearGradient(0, 0, 400, 0);
gradient.addColorStop(0, "#f97316");
gradient.addColorStop(1, "#7c3aed");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 400, 250);
ctx.fillStyle = "white";
ctx.font = "bold 32px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Hello Canvas", 200, 125);
createLinearGradient(x0, y0, x1, y1) defines a transition between two points, here from left to right. addColorStop places colors at positions between 0 and 1. For a transition that radiates outward from a circle there's createRadialGradient. For text, font is written exactly like the font shorthand in CSS. textAlign and textBaseline decide which part of the text lands on the point you give; set them to center and middle and the text is centered right on that point. Because this gradient covers the whole canvas, it painted over the earlier shapes. Order matters on a canvas: whatever is drawn later is painted on top of what came before.
Drawing Images and Touching Pixels
drawImage draws an image, the current frame of a video, or another canvas onto the canvas. In the previous post we read files all the way down to their bytes; now let's draw an image the user picks and turn it grayscale:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Grayscale</title>
<style>canvas { display: block; margin-top: 12px; border: 1px solid #ccc; }</style>
</head>
<body>
<input type="file" id="picker" accept="image/*">
<button id="gray" disabled>Make it gray</button>
<canvas id="canvas" width="400" height="300"></canvas>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const grayButton = document.getElementById("gray");
document.getElementById("picker").addEventListener("change", async (event) => {
const img = new Image();
img.src = URL.createObjectURL(event.target.files[0]);
await img.decode(); // if we draw before the image is ready, nothing shows up
URL.revokeObjectURL(img.src);
const ratio = Math.min(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight);
const w = img.naturalWidth * ratio;
const h = img.naturalHeight * ratio;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
grayButton.disabled = false;
});
grayButton.addEventListener("click", () => {
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const d = image.data; // R, G, B, A, R, G, B, A, ...
for (let i = 0; i < d.length; i += 4) {
const gray = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = d[i + 1] = d[i + 2] = gray;
}
ctx.putImageData(image, 0, 0);
});
</script>
</body>
</html>
drawImage(img, x, y, width, height) draws the image scaled to the size you want. We compute the ratio so the image fits the canvas without distortion, and center it. img.decode() returns a promise that resolves as soon as the image is decoded and ready to draw; it's the modern way of waiting for the load event.
The really interesting part is getImageData. This method gives you a region of the canvas, pixel by pixel, as an ImageData object. Its data field is the Uint8ClampedArray we met in the previous post. Each pixel is stored as four consecutive bytes: red, green, blue and transparency (alpha), all between 0 and 255. On a 400 × 300 canvas that's 480,000 bytes. We step through the loop four at a time and set all three colors of each pixel to the same gray value. The coefficients in the formula come from the human eye being more sensitive to green. The value we compute has a fractional part, but that's fine; the clamped array rounds it and keeps it within 0–255. putImageData then writes the modified pixels back to the canvas.
Animation with requestAnimationFrame
Animation on a canvas is like the flip-book cartoons you drew in the corner of your notebook as a kid. On every page the ball is a little further along; flip the pages fast and you see motion. On a canvas, every frame repeats three steps: update the state, clear the canvas, draw again. That loop is driven by requestAnimationFrame, which we saw earlier in the event loop and performance posts:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bouncing circle</title>
<style>canvas { border: 1px solid #ccc; }</style>
</head>
<body>
<canvas id="canvas" width="400" height="250"></canvas>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const circle = { x: 50, y: 50, r: 15, vx: 180, vy: 120 }; // speed: pixels/second
let lastTime = performance.now();
function frame(now) {
const dt = Math.min((now - lastTime) / 1000, 0.05); // seconds, at most 50 ms
lastTime = now;
// 1. Update
circle.x += circle.vx * dt;
circle.y += circle.vy * dt;
if (circle.x < circle.r || circle.x > canvas.width - circle.r) {
circle.vx *= -1;
circle.x = Math.min(Math.max(circle.x, circle.r), canvas.width - circle.r);
}
if (circle.y < circle.r || circle.y > canvas.height - circle.r) {
circle.vy *= -1;
circle.y = Math.min(Math.max(circle.y, circle.r), canvas.height - circle.r);
}
// 2. Clear, 3. Draw
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI * 2);
ctx.fillStyle = "tomato";
ctx.fill();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
requestAnimationFrame calls its callback right before the browser's next screen refresh and passes it a timestamp on the same scale as performance.now(). We keep the speed as "pixels per second" rather than "pixels per frame" and multiply it by the time elapsed between two frames (dt). That way the circle moves at the same speed on a 60 Hz screen and on a 120 Hz screen. When the tab goes into the background, the browser pauses this callback; we cap dt at 50 milliseconds so it doesn't come back huge when you return. When the circle hits an edge, we reverse its speed and push it back inside the boundary; otherwise a circle that slightly overshot could flip direction every frame and get stuck to the wall.
Take a look at the variable names too. For the edge checks it would feel natural to declare a constant named top for the upper boundary, but if you had written const top, the page would have failed to load with "Identifier 'top' has already been declared". window.top already exists in the browser, and at the top level of a regular script you can't declare a variable with the same name using let or const. A small surprise that pops up now and then when you pick short, common names.
Sharp Output: devicePixelRatio
The answer to the opening question: 300 150, then 300 again. A canvas has two separate sizes. The width and height attributes are the size of the drawing buffer, the grid of pixels that actually gets painted; if you don't set them, the default is 300 × 150. The width in CSS decides how big an area on screen that grid is spread across. When you make it 600 pixels with CSS, the grid doesn't grow, it stretches. It's like blowing up a 300-pixel photo to twice its size; the result is blurry.
The same problem shows up on its own on retina screens. On these screens one CSS pixel maps to two or three physical pixels, and the ratio lives in window.devicePixelRatio. Scale the drawing buffer up by that ratio, keep the CSS size fixed, and the drawing becomes sharp:
function makeSharp(canvas, cssWidth, cssHeight) {
const dpr = window.devicePixelRatio || 1;
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); // from now on, draw in CSS pixels
return ctx;
}
const ctx = makeSharp(document.getElementById("canvas"), 400, 250);
With setTransform we scale every drawing by dpr, so you can keep thinking in CSS pixels: fillRect(20, 20, 120, 80) lands in the same place at the same size on every screen, but comes out sharp. We use setTransform instead of scale() so that the scale doesn't stack up if the function is called a second time.
Common Pitfalls
Forgetting to call beginPath() is the most common mistake. If you don't reset the sketch before starting a new shape, the previous shapes stay in it too, and the next stroke() or fill() paints all of them again in the new color. In an animation, that means a sketch that grows every frame and a page that gets slower and slower.
Assigning a value to canvas.width or canvas.height, even the same value, wipes the canvas completely and resets the entire context state: colors, line width, scale. After changing the size, set your styles and setTransform again.
If you call drawImage before the image has loaded, you don't get an error; nothing is drawn. Silent failures are the hardest to track down; always wait for decode() or the load event.
If you draw an image that comes from another origin without CORS permission, the canvas becomes "tainted". The drawing still shows up, but getImageData throws a SecurityError; the browser won't let you read the pixels of another site's image. Files picked by the user don't have this problem, because the blob: address belongs to your page.
Calling getImageData every frame is expensive, because the browser may have to read the pixels back from the graphics card. If you read often, request the context with canvas.getContext("2d", { willReadFrequently: true }); this hints to the browser that it should pick a path suited to reading.
Finally, accessibility: the text and shapes you draw on a canvas are invisible to screen readers. If it carries important information, provide the same information as fallback content or as text next to the canvas.
A Small Experiment
★☆☆ In the file from the first example, draw a simple house using fillRect and a path: a square body, a triangular roof, a rectangular door and a round window.
★★☆ Predict the colors of the two squares before running it, then try it:
ctx.fillStyle = "red";
ctx.save();
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 50, 50);
ctx.restore();
ctx.fillRect(60, 0, 50, 50);
★★★ Extend the bouncing circle example: on every click on the canvas, add a new circle at the clicked point with a random color and a random speed. Keep the circles in an array and update and draw all of them every frame. Hint: for the click coordinates, look at the event's offsetX and offsetY fields.
On the canvas we drew and managed every pixel ourselves; the browser had no idea what was there. In the next post we go the opposite way: we'll define new HTML tags that the browser knows about, that live in the DOM and have their own style and behavior. Custom Elements, Shadow DOM and slots are up next.