Skip to content
JavaScript projects 5 min read

Project: Canvas Paint App

A paint app is the perfect project for tying together three core browser skills: drawing on <canvas>, listening to pointer events, and wiring up DOM controls. You track where the mouse goes, draw line segments between successive points, and expose buttons and inputs that change the brush. By the end you will also export the finished artwork as a downloadable PNG. It looks polished, but the entire thing fits in a single file with no dependencies.

How canvas drawing works

The <canvas> element is just a pixel grid. You never draw to it directly — instead you ask its 2D rendering context for a set of imperative drawing commands. To draw freehand, you do not render a single continuous curve; you connect a series of tiny straight segments as the mouse moves. Because the events fire dozens of times per second, those segments blend into a smooth stroke.

const canvas = document.querySelector("#board");
const ctx = canvas.getContext("2d");

ctx.lineCap = "round";   // rounded segment ends
ctx.lineJoin = "round";  // smooth corners between segments
ctx.lineWidth = 8;
ctx.strokeStyle = "#2563eb";

lineCap and lineJoin set to "round" are what make the brush feel natural rather than blocky. Each stroke is bracketed by beginPath() (start a new line) and stroke() (paint it).

Tracking the mouse

Drawing is a state machine with three events. On mousedown you flip a drawing flag on and record the starting point. On mousemove, only if drawing is true, you draw a segment to the new point. On mouseup (or mouseleave) you flip the flag off.

One gotcha: event.clientX/Y are relative to the viewport, not the canvas. Subtract the canvas position from getBoundingClientRect() to convert to canvas-local coordinates.

const pos = (e) => {
  const rect = canvas.getBoundingClientRect();
  return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};

let drawing = false;
let last = { x: 0, y: 0 };

canvas.addEventListener("mousedown", (e) => {
  drawing = true;
  last = pos(e);
});

canvas.addEventListener("mousemove", (e) => {
  if (!drawing) return;
  const p = pos(e);
  ctx.beginPath();
  ctx.moveTo(last.x, last.y);
  ctx.lineTo(p.x, p.y);
  ctx.stroke();
  last = p;
});

["mouseup", "mouseleave"].forEach((evt) =>
  canvas.addEventListener(evt, () => (drawing = false))
);

Tip: Listen for mouseup on window, not the canvas, if you want strokes to keep their state even when the pointer is released outside the canvas. For a simpler app, mouseleave is enough.

Brush controls

The brush is defined entirely by strokeStyle (color) and lineWidth (size). Bind a <input type="color"> and a <input type="range"> to those two properties and the controls work instantly — no redraw needed, since they only affect future strokes.

ControlElementContext propertyNotes
Color<input type="color">ctx.strokeStyleReturns a hex string like #ff0000
Brush size<input type="range">ctx.lineWidthRead e.target.value (a string)
Eraserbuttonctx.strokeStyle = "#fff"”Erasing” is just drawing in the background color
Clearbuttonctx.clearRect(...)Wipes the whole canvas

Clearing and saving

Clearing is one call: ctx.clearRect(0, 0, canvas.width, canvas.height). To save, canvas.toDataURL("image/png") serializes the pixels to a base64 data URL. Attach that to an <a download> and trigger a click to prompt a download.

function save() {
  const link = document.createElement("a");
  link.download = "painting.png";
  link.href = canvas.toDataURL("image/png");
  link.click();
}

Output:

A file named "painting.png" downloads containing your drawing.

Full working app

Here is the complete, self-contained paint app — markup, styles, and logic in one file.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
  body { font-family: system-ui, sans-serif; padding: 16px; }
  .toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
  button { padding: 6px 12px; cursor: pointer; }
  canvas { border: 2px solid #cbd5e1; border-radius: 8px; cursor: crosshair; touch-action: none; }
</style>
</head>
<body>
  <div class="toolbar">
    <label>Color <input type="color" id="color" value="#2563eb" /></label>
    <label>Size <input type="range" id="size" min="1" max="40" value="8" /></label>
    <button id="erase">Eraser</button>
    <button id="clear">Clear</button>
    <button id="save">Save PNG</button>
  </div>
  <canvas id="board" width="600" height="380"></canvas>

  <script>
    const canvas = document.getElementById("board");
    const ctx = canvas.getContext("2d");
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    ctx.lineWidth = 8;
    ctx.strokeStyle = "#2563eb";

    let drawing = false;
    let last = { x: 0, y: 0 };

    const pos = (e) => {
      const rect = canvas.getBoundingClientRect();
      return { x: e.clientX - rect.left, y: e.clientY - rect.top };
    };

    canvas.addEventListener("mousedown", (e) => {
      drawing = true;
      last = pos(e);
    });

    canvas.addEventListener("mousemove", (e) => {
      if (!drawing) return;
      const p = pos(e);
      ctx.beginPath();
      ctx.moveTo(last.x, last.y);
      ctx.lineTo(p.x, p.y);
      ctx.stroke();
      last = p;
    });

    ["mouseup", "mouseleave"].forEach((evt) =>
      canvas.addEventListener(evt, () => (drawing = false))
    );

    document.getElementById("color").addEventListener("input", (e) => {
      ctx.strokeStyle = e.target.value;
    });

    document.getElementById("size").addEventListener("input", (e) => {
      ctx.lineWidth = Number(e.target.value);
    });

    document.getElementById("erase").addEventListener("click", () => {
      ctx.strokeStyle = "#ffffff";
    });

    document.getElementById("clear").addEventListener("click", () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    });

    document.getElementById("save").addEventListener("click", () => {
      const link = document.createElement("a");
      link.download = "painting.png";
      link.href = canvas.toDataURL("image/png");
      link.click();
    });
  </script>
</body>
</html>

Notice touch-action: none in the CSS — it stops the browser from scrolling when you drag on the canvas, which is essential if you later add touch (pointer) events.

Touch and pointer support

To support phones and tablets with the same code, swap the three mouse* events for pointer events (pointerdown, pointermove, pointerup). They unify mouse, touch, and stylus under one API and expose e.pressure for pressure-sensitive brushes.

// Pointer events work for mouse, touch, and stylus alike.
const el = document.body;
el.addEventListener("pointerdown", (e) => {
  console.log(`down: ${e.pointerType} at ${Math.round(e.clientX)},${Math.round(e.clientY)}`);
});

Output:

down: mouse at 142,310
down: touch at 88,205

Best practices

  • Convert viewport coordinates to canvas-local space with getBoundingClientRect(); never assume the canvas sits at 0,0.
  • Set lineCap and lineJoin to "round" once so strokes look smooth, then reuse the context across events.
  • Treat the eraser as drawing in the background color rather than a separate mode — far less code.
  • Use pointer events instead of mouse events to support touch devices for free, and add touch-action: none to prevent scroll hijacking.
  • Read <input> values with Number() since they arrive as strings.
  • For an undo feature, push ctx.getImageData(...) snapshots onto a stack on each mousedown and restore with putImageData.
Last updated June 1, 2026
Was this helpful?