Handling Mouse & Keyboard Input
A <canvas> is just a bitmap — it has no DOM elements inside it, so there are no buttons or shapes for the browser to dispatch events to. Instead, you listen for plain mouse and keyboard events on the canvas (or the window), translate the event’s screen coordinates into canvas coordinates, and decide for yourself what was clicked. Mastering that translation and a little hit testing is what turns a static drawing into an interactive game, editor, or chart.
Getting canvas-relative coordinates
Mouse events report position in viewport coordinates via event.clientX and event.clientY. The canvas, however, lives somewhere on the page and may be scaled by CSS. To convert, call getBoundingClientRect() to find the canvas’s on-screen position and size, then subtract and rescale.
function getCanvasPos(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width; // bitmap px per CSS px
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}
The scaleX/scaleY factors matter whenever the canvas’s CSS display size differs from its width/height attributes (the drawing buffer). Skipping them is the single most common reason a click “lands” in the wrong spot.
Tip: Cache the result of
getBoundingClientRect()only briefly. It changes whenever the page scrolls, resizes, or the layout shifts, so read it fresh inside the event handler rather than once at startup.
Tracking the mouse and clicks
With the conversion helper in place, the relevant events are straightforward. mousemove fires continuously as the pointer moves, mousedown/mouseup fire on press and release, and click fires after a press-and-release on the same element.
| Event | Fires when | Common use |
|---|---|---|
mousemove | The pointer moves over the canvas | Hover effects, cursor tracking, drawing |
mousedown | A button is pressed | Start a drag, begin a stroke |
mouseup | A button is released | End a drag |
click | Press and release on the same target | Discrete actions, selection |
contextmenu | Right-click | Custom menus (call preventDefault()) |
wheel | The scroll wheel turns | Zoom |
const canvas = document.querySelector("#scene");
canvas.addEventListener("mousemove", (event) => {
const { x, y } = getCanvasPos(canvas, event);
console.log(`Pointer at ${x.toFixed(0)}, ${y.toFixed(0)}`);
});
Output:
Pointer at 142, 87
Pointer at 145, 90
Pointer at 150, 96
For touch and pen support with one code path, prefer pointer events (pointerdown, pointermove, pointerup) — they share the same clientX/clientY properties, so the same helper works unchanged.
Reading keyboard state for movement
Keyboard handling for games is different from typing. You don’t want a single keydown and then the OS key-repeat delay; you want to know which keys are held right now so movement is smooth. The pattern is to maintain a keys set and toggle entries on keydown/keyup, then read that set inside your animation loop.
const keys = new Set();
window.addEventListener("keydown", (e) => keys.add(e.code));
window.addEventListener("keyup", (e) => keys.delete(e.code));
function update(player, speed) {
if (keys.has("ArrowUp") || keys.has("KeyW")) player.y -= speed;
if (keys.has("ArrowDown") || keys.has("KeyS")) player.y += speed;
if (keys.has("ArrowLeft") || keys.has("KeyA")) player.x -= speed;
if (keys.has("ArrowRight") || keys.has("KeyD")) player.x += speed;
}
Use event.code (the physical key, like "KeyW") for game controls so the layout is keyboard-independent, and event.key (the produced character, like "w") for text. Call event.preventDefault() for keys such as the arrows or spacebar to stop the page from scrolling.
Hit testing: point in rectangle and circle
Once you have a canvas-space point, “what did I click?” becomes pure geometry. A point is inside a rectangle when it falls between the edges on both axes:
function pointInRect(px, py, rect) {
return (
px >= rect.x &&
px <= rect.x + rect.w &&
py >= rect.y &&
py <= rect.y + rect.h
);
}
For a circle, compare the squared distance from the center to the squared radius — squaring avoids a Math.sqrt call and gives the same answer:
function pointInCircle(px, py, cx, cy, r) {
const dx = px - cx;
const dy = py - cy;
return dx * dx + dy * dy <= r * r;
}
When shapes overlap, iterate from the topmost (last drawn) backward so the visually frontmost shape wins the hit, matching what the user sees.
Putting it together: drag a shape
This pen wires up everything above. On mousedown it hit-tests the box; while dragging it follows the pointer (keeping the grab offset so the shape doesn’t jump); on mouseup it releases. Drag the blue square around.
<canvas id="drag" width="360" height="240" style="background:#0f172a;cursor:grab"></canvas>
<script>
const canvas = document.getElementById("drag");
const ctx = canvas.getContext("2d");
const box = { x: 140, y: 90, w: 80, h: 60 };
let dragging = false;
let offsetX = 0, offsetY = 0;
function getPos(e) {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left) * (canvas.width / r.width),
y: (e.clientY - r.top) * (canvas.height / r.height),
};
}
function inBox(px, py) {
return px >= box.x && px <= box.x + box.w &&
py >= box.y && py <= box.y + box.h;
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = dragging ? "#f97316" : "#38bdf8";
ctx.fillRect(box.x, box.y, box.w, box.h);
ctx.fillStyle = "#e2e8f0";
ctx.font = "14px sans-serif";
ctx.fillText("Drag me", box.x + 8, box.y + 35);
}
canvas.addEventListener("mousedown", (e) => {
const { x, y } = getPos(e);
if (inBox(x, y)) {
dragging = true;
offsetX = x - box.x;
offsetY = y - box.y;
canvas.style.cursor = "grabbing";
draw();
}
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const { x, y } = getPos(e);
box.x = x - offsetX;
box.y = y - offsetY;
draw();
});
window.addEventListener("mouseup", () => {
dragging = false;
canvas.style.cursor = "grab";
draw();
});
draw();
</script>
Notice that mousemove and mouseup are attached to window, not the canvas. That lets the drag keep working even when the pointer briefly leaves the canvas — a small detail that makes interaction feel solid.
Best Practices
- Always convert with
getBoundingClientRect()and scale bycanvas.width / rect.widthso clicks stay accurate when CSS resizes the canvas. - Read the bounding rect inside the handler, not once at startup, because scrolling and resizing invalidate it.
- Track held keys in a
Setand act on them in your loop; don’t move objects directly insidekeydown. - Use
event.codefor game controls andevent.keyfor text entry, andpreventDefault()for arrows, space, and right-click. - Attach drag
mousemove/mouseuptowindowso a drag survives the pointer leaving the canvas. - Prefer pointer events (
pointerdown/pointermove/pointerup) to support mouse, touch, and pen with one set of handlers.