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
mouseuponwindow, not the canvas, if you want strokes to keep their state even when the pointer is released outside the canvas. For a simpler app,mouseleaveis 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.
| Control | Element | Context property | Notes |
|---|---|---|---|
| Color | <input type="color"> | ctx.strokeStyle | Returns a hex string like #ff0000 |
| Brush size | <input type="range"> | ctx.lineWidth | Read e.target.value (a string) |
| Eraser | button | ctx.strokeStyle = "#fff" | ”Erasing” is just drawing in the background color |
| Clear | button | ctx.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 at0,0. - Set
lineCapandlineJointo"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: noneto prevent scroll hijacking. - Read
<input>values withNumber()since they arrive as strings. - For an undo feature, push
ctx.getImageData(...)snapshots onto a stack on eachmousedownand restore withputImageData.