Canvas Graphics

JavaScript · Lesson 40 of 61 9 min read Updated

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:

HTML
<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 current fillStyle.
  • strokeRect(x, y, width, height) draws an outline in the current strokeStyle and lineWidth.
  • 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.

  1. beginPath() starts a new, empty path.
  2. moveTo(x, y) lifts the pen and moves it without drawing.
  3. lineTo(x, y) draws a straight line to a point.
  4. arc(x, y, radius, startAngle, endAngle) adds part or all of a circle.
  5. fill() or stroke() 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:

HTMLTry it yourself
<!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:

HTMLTry it yourself
<!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:

HTMLTry it yourself
<!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:

HTMLTry it yourself
<!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:

JavaScript
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 units

Canvas 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 width and height attributes.
  • Forgetting beginPath(). Old shapes are redrawn with the new style.
  • Using degrees in arc(). Convert with degrees * Math.PI / 180.
  • Drawing an image before it has loaded. Nothing appears. Wait with await img.decode() or the load event.
  • 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 fillRect and strokeRect; everything else is a path built with beginPath, moveTo, lineTo and arc.
  • Angles are in radians: a full circle is 2 * Math.PI.
  • fillText, font and textAlign draw text; drawImage draws loaded images.
  • Animate by clearing and redrawing in a requestAnimationFrame loop, 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
HTML
<!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?

2. Which value draws a full circle with arc()?

3. Why does a canvas look blurry when you make it bigger with CSS only?

4. What are the three steps in each frame of a canvas animation?

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