Canvas Graphics
Charts on a stock market app, a signature pad on a courier's phone, a simple browser game, a photo filter: all of these are drawn pixel by pixel with the <canvas> element. A canvas is a blank rectangle of pixels, and JavaScript paints on it with drawing commands.
In this lesson you will set up a canvas, draw rectangles, lines, circles and text, place an image, build a small bar chart, and animate a ball with requestAnimationFrame.
Setting up a canvas
Add a <canvas> element with a width and height in pixels, then ask it for a 2D rendering context, the object that holds all the drawing methods:
<canvas id="board" width="400" height="250">
A drawing of the Indian flag.
</canvas>
<script>
const canvas = document.querySelector("#board");
const ctx = canvas.getContext("2d"); // ctx is the usual short name
</script>The text inside the element is fallback content: it is shown only by browsers that cannot draw the canvas, and it gives screen readers something to read. A canvas is just pixels, so describe what it shows.
The coordinate system starts at the top-left corner. x grows to the right and y grows downwards, which is the opposite of the graphs you drew in school maths. The point (0, 0) is the top-left, and (400, 250) is the bottom-right of this canvas.
Rectangles and colours
Rectangles are the only shape with their own methods:
fillRect(x, y, width, height)draws a filled rectangle in the currentfillStyle.strokeRect(x, y, width, height)draws an outline in the currentstrokeStyleandlineWidth.clearRect(x, y, width, height)erases an area back to transparent.
Colours are any CSS colour string: "orange", "#138808", "rgb(0 0 128)" or "oklch(70% 0.15 60)". The style stays set until you change it, like choosing a pen and keeping it in your hand.
Paths: lines, shapes and circles
Every other shape is a path: you describe an outline step by step, then fill it or stroke it.
beginPath()starts a new, empty path.moveTo(x, y)lifts the pen and moves it without drawing.lineTo(x, y)draws a straight line to a point.arc(x, y, radius, startAngle, endAngle)adds part or all of a circle.fill()orstroke()actually paints the path.
Angles in canvas are in radians, not degrees. A full circle is 2 * Math.PI (about 6.28), and half a circle is Math.PI.
This example draws the Indian flag with rectangles, a circle and 24 spokes made from lines:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Canvas flag</title>
</head>
<body>
<canvas id="flag" width="300" height="200">The Indian national flag.</canvas>
<script>
const ctx = document.querySelector("#flag").getContext("2d");
const w = 300, h = 200, band = h / 3;
// Three bands
ctx.fillStyle = "#ff9933";
ctx.fillRect(0, 0, w, band);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, band, w, band);
ctx.fillStyle = "#138808";
ctx.fillRect(0, band * 2, w, band);
// Ashoka Chakra: a circle with 24 spokes
const cx = w / 2, cy = h / 2, r = band / 2 - 4;
ctx.strokeStyle = "#000080";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
ctx.stroke();
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 24; i++) {
const angle = (i / 24) * 2 * Math.PI;
ctx.moveTo(cx, cy);
ctx.lineTo(cx + r * Math.cos(angle), cy + r * Math.sin(angle));
}
ctx.stroke();
// Thin border around the whole flag
ctx.strokeStyle = "#999999";
ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
</script>
</body>
</html>Each spoke starts at the centre and ends on the circle. Math.cos(angle) and Math.sin(angle) give the x and y direction for an angle, and multiplying by the radius gives the end point. All 24 lines belong to one path, so one stroke() draws them together, which is faster than 24 separate strokes.
Always call beginPath() before a new shape. If you forget, the old path is still there, and the next fill() or stroke() paints the old shapes again in the new colour.
Drawing text
Text uses fillText(text, x, y) (or strokeText for an outline). Set the font with the same syntax as the CSS font shorthand. textAlign ("left", "center", "right") and textBaseline ("top", "middle", "alphabetic", "bottom") decide how the text sits around the point you give. measureText(text).width tells you how wide a piece of text will be.
Text, rectangles and a loop are all you need for a bar chart. Here are the runs scored by five batters:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bar chart</title>
</head>
<body>
<canvas id="chart" width="420" height="260">Bar chart of runs scored by five batters.</canvas>
<script>
const ctx = document.querySelector("#chart").getContext("2d");
const scores = [
{ name: "Rohan", runs: 48 },
{ name: "Aarav", runs: 72 },
{ name: "Kabir", runs: 31 },
{ name: "Isha", runs: 95 },
{ name: "Farhan", runs: 60 },
];
const chartTop = 55, chartBottom = 220, left = 40, barWidth = 50, gap = 25;
const maxRuns = Math.max(...scores.map((s) => s.runs));
const scale = (chartBottom - chartTop) / maxRuns; // pixels per run
ctx.font = "bold 16px system-ui, sans-serif";
ctx.fillStyle = "#222";
ctx.textAlign = "center";
ctx.fillText("Runs in the final, Rohtak Cricket League", 210, 20);
ctx.font = "13px system-ui, sans-serif";
scores.forEach((s, i) => {
const x = left + i * (barWidth + gap);
const height = s.runs * scale;
const y = chartBottom - height;
ctx.fillStyle = s.runs === maxRuns ? "#c99a00" : "#4a7bd0";
ctx.fillRect(x, y, barWidth, height);
ctx.fillStyle = "#222";
ctx.textBaseline = "bottom";
ctx.fillText(s.runs, x + barWidth / 2, y - 2); // value above the bar
ctx.textBaseline = "top";
ctx.fillText(s.name, x + barWidth / 2, chartBottom + 6); // name below
});
// Axis line
ctx.beginPath();
ctx.moveTo(left - 10, chartBottom + 0.5);
ctx.lineTo(410, chartBottom + 0.5);
ctx.stroke();
</script>
</body>
</html>The tallest bar is coloured gold. Because the bar heights are calculated from maxRuns, the chart still fits if you change the scores. For real projects, a chart library saves time, but most of them draw on a canvas exactly like this.
Drawing images
drawImage() paints an image, another canvas or a video frame onto the canvas. It has three forms:
drawImage(img, x, y)draws at the image's natural size.drawImage(img, x, y, width, height)scales it.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh)cuts a part of the source and draws it into a target box.
An image must finish loading before you draw it. await img.decode() waits for that and works in a module script:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Canvas images</title>
</head>
<body>
<canvas id="card" width="360" height="200">A profile card with a photo and a name.</canvas>
<script type="module">
const ctx = document.querySelector("#card").getContext("2d");
ctx.fillStyle = "#f3efe6";
ctx.fillRect(0, 0, 360, 200);
const photo = new Image();
photo.src = "https://course.ethicscomputer.in/assets/samples/avatar.svg";
await photo.decode(); // wait until the image has loaded
// Clip to a circle, draw the photo inside it, then remove the clip
ctx.save();
ctx.beginPath();
ctx.arc(100, 100, 70, 0, 2 * Math.PI);
ctx.clip();
ctx.drawImage(photo, 30, 30, 140, 140);
ctx.restore();
ctx.fillStyle = "#222";
ctx.font = "bold 22px system-ui, sans-serif";
ctx.fillText("Ananya Rao", 190, 95);
ctx.font = "15px system-ui, sans-serif";
ctx.fillText("Class 12, Kochi", 190, 122);
</script>
</body>
</html>save() stores the current settings (colours, font, clip area) and restore() brings them back. Wrapping the clip between them means only the photo is cut into a circle.
You can read a finished drawing back: canvas.toDataURL("image/png") gives a data URL and canvas.toBlob(callback) gives a file-like Blob (see Typed Arrays, Blobs & Base64), which is how "download your certificate" or "crop your profile photo" features work. Note that drawing an image from another website without permission (CORS) "taints" the canvas, and the browser then blocks reading the pixels for security.
Animation with requestAnimationFrame
A canvas does not remember shapes; it only remembers pixels. To animate, you redraw the whole scene many times a second. Every frame follows the same three steps: clear, update the positions, draw. You learned requestAnimationFrame in Timers & Animation Frames; here it drives a bouncing ball:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bouncing ball</title>
</head>
<body>
<canvas id="court" width="400" height="200" style="border: 1px solid #999">A ball bouncing inside a box.</canvas>
<p><button type="button" id="toggle">Pause</button></p>
<script>
const canvas = document.querySelector("#court");
const ctx = canvas.getContext("2d");
const ball = { x: 60, y: 50, r: 14, vx: 180, vy: 120 }; // speed in pixels per second
let last = null;
let running = true;
function frame(time) {
if (!running) return;
// seconds since the last frame (at most 0.1, in case the tab was hidden)
const dt = last === null ? 0 : Math.min((time - last) / 1000, 0.1);
last = time;
// update: move, then bounce off the walls
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x < ball.r) ball.vx = Math.abs(ball.vx);
if (ball.x > canvas.width - ball.r) ball.vx = -Math.abs(ball.vx);
if (ball.y < ball.r) ball.vy = Math.abs(ball.vy);
if (ball.y > canvas.height - ball.r) ball.vy = -Math.abs(ball.vy);
// clear and draw
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, 2 * Math.PI);
ctx.fillStyle = "crimson";
ctx.fill();
requestAnimationFrame(frame);
}
document.querySelector("#toggle").addEventListener("click", (event) => {
running = !running;
event.target.textContent = running ? "Pause" : "Play";
if (running) {
last = null;
requestAnimationFrame(frame);
}
});
requestAnimationFrame(frame);
</script>
</body>
</html>The speed is in pixels per second and is multiplied by the time since the last frame (dt), so the ball moves at the same speed on a 60 Hz phone and a 144 Hz gaming monitor. Setting the direction with Math.abs() at each wall, instead of simply flipping the sign, stops the ball from getting stuck outside the box after a long pause.
Advanced: sharp drawings on high-resolution screens
Most phones have a devicePixelRatio of 2 or 3: each CSS pixel is made of several physical pixels. A 300 × 200 canvas then looks slightly blurry. The fix is to give the canvas more real pixels, keep its CSS size the same, and scale the drawing commands:
const ratio = window.devicePixelRatio || 1;
canvas.style.width = "300px";
canvas.style.height = "200px";
canvas.width = 300 * ratio;
canvas.height = 200 * ratio;
ctx.scale(ratio, ratio); // keep drawing in CSS pixel unitsCanvas or SVG? A canvas is best for many moving objects, pixel effects and games. For a few shapes that need to stay sharp, be clickable and be read by screen readers, such as icons and simple diagrams, SVG is usually the better tool.
Common mistakes
- Sizing the canvas only with CSS. The drawing is stretched and blurry. Use the
widthandheightattributes. - Forgetting
beginPath(). Old shapes are redrawn with the new style. - Using degrees in
arc(). Convert withdegrees * Math.PI / 180. - Drawing an image before it has loaded. Nothing appears. Wait with
await img.decode()or theloadevent. - Not clearing between frames. The animation leaves a smeared trail. Call
clearRect()first. - Leaving out fallback text. Screen reader users get nothing from the pixels.
Key points
- A
<canvas>is a grid of pixels;getContext("2d")gives the drawing methods. - (0, 0) is the top-left corner and y grows downwards.
- Rectangles have
fillRectandstrokeRect; everything else is a path built withbeginPath,moveTo,lineToandarc. - Angles are in radians: a full circle is
2 * Math.PI. fillText,fontandtextAligndraw text;drawImagedraws loaded images.- Animate by clearing and redrawing in a
requestAnimationFrameloop, using the elapsed time.
Exercise
Draw a simple traffic signal on a 120 × 300 canvas: a dark grey rounded box (or plain rectangle) with three circles for red, yellow and green. Then make it change: every two seconds, only one light is bright and the other two are dim grey, in the order red, green, yellow. Use a function draw(activeIndex) that redraws everything.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Traffic signal</title>
</head>
<body>
<canvas id="signal" width="120" height="300">A traffic signal that changes colour.</canvas>
<script>
const ctx = document.querySelector("#signal").getContext("2d");
const lights = [
{ color: "red", y: 60 },
{ color: "gold", y: 150 },
{ color: "limegreen", y: 240 },
];
const order = [0, 2, 1]; // red, green, yellow
let step = 0;
function draw(activeIndex) {
ctx.clearRect(0, 0, 120, 300);
ctx.fillStyle = "#333";
ctx.fillRect(10, 10, 100, 280);
lights.forEach((light, i) => {
ctx.beginPath();
ctx.arc(60, light.y, 35, 0, 2 * Math.PI);
ctx.fillStyle = i === activeIndex ? light.color : "#555";
ctx.fill();
});
}
draw(order[step]);
setInterval(() => {
step = (step + 1) % order.length;
draw(order[step]);
}, 2000);
</script>
</body>
</html>draw() clears and repaints the whole signal each time, which is the normal canvas pattern. A two-second change does not need requestAnimationFrame; setInterval is fine for slow, step-by-step updates.
Quick Quiz
1. Where is the point (0, 0) on a canvas?
Canvas coordinates start at the top-left, with x growing right and y growing down.
2. Which value draws a full circle with arc()?
Angles are measured in radians, and a full turn is 2π radians.
3. Why does a canvas look blurry when you make it bigger with CSS only?
The attributes decide the real pixel grid, and CSS scaling only stretches it.
4. What are the three steps in each frame of a canvas animation?
The canvas keeps only pixels, so each frame clears the old picture, moves things, and draws again.
Learn JavaScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
