HTML canvas zoom and pan

Keep a scale and an offset, apply them with setTransform, and redraw. Zooming with a CSS transform is the shortcut that looks right until you zoom past two times and everything softens.

To zoom and pan an HTML canvas, hold a scale value and an x and y offset in one state object, apply them with setTransform at the top of every render, and redraw the whole scene.

const view = { scale: 1, x: 0, y: 0 };

function render() {
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.setTransform(view.scale, 0, 0, view.scale, view.x, view.y);
  drawScene(ctx);
}

The first setTransform resets to identity so the clear covers the real bitmap. Skip it and the cleared region moves with the view, leaving trails.

A zoomed canvas diagram with text and thin lines still rendering sharply at four times scale.
A zoomed canvas diagram with text and thin lines still rendering sharply at four times scale.

Why not CSS transform

transform: scale(3) on the canvas element is one line and it works for a moment. It scales the bitmap you already drew, so every line thickens and every label softens.

Approach Stays sharp Cost
CSS transform on the element No One line, no redraw
Context transform plus redraw Yes Redraw on every change
Larger bitmap, no zoom Yes, up to its size Memory grows with the square

Context transform is the only one that holds at arbitrary zoom. It costs a redraw per frame, which is cheap for a few thousand shapes.

Panning with pointer events

Use pointer events rather than mouse events, and you get touch and stylus without a second code path.

let dragging = false, lastX = 0, lastY = 0;

canvas.addEventListener('pointerdown', e => {
  dragging = true;
  lastX = e.clientX; lastY = e.clientY;
  canvas.setPointerCapture(e.pointerId);
});

canvas.addEventListener('pointermove', e => {
  if (!dragging) return;
  view.x += e.clientX - lastX;
  view.y += e.clientY - lastY;
  lastX = e.clientX; lastY = e.clientY;
  render();
});

canvas.addEventListener('pointerup', e => {
  dragging = false;
  canvas.releasePointerCapture(e.pointerId);
});

setPointerCapture is what stops the drag dying when the pointer leaves the canvas. Without it, a fast drag out of the element leaves the view stuck mid move.

Add canvas { touch-action: none; } in CSS, or a drag on a phone scrolls the page instead of moving the scene.

Zooming toward the pointer

Scaling around the origin feels wrong, because the thing under the cursor slides away. Anchor it instead.

canvas.addEventListener('wheel', e => {
  e.preventDefault();
  const rect = canvas.getBoundingClientRect();
  const px = e.clientX - rect.left;
  const py = e.clientY - rect.top;

  const wx = (px - view.x) / view.scale;
  const wy = (py - view.y) / view.scale;

  const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
  view.scale = Math.min(40, Math.max(0.1, view.scale * factor));

  view.x = px - wx * view.scale;
  view.y = py - wy * view.scale;
  render();
}, { passive: false });

Read it as four steps. Find the world point under the pointer. Change the scale. Put that world point back under the pointer. Redraw.

{ passive: false } is required. Wheel listeners default to passive on many browsers, and preventDefault is then ignored, so the page scrolls underneath.

A canvas mid zoom with the cursor over a labelled node, the node holding its position on screen as the scale increases.
A canvas mid zoom with the cursor over a labelled node, the node holding its position on screen as the scale increases.

Converting screen coordinates back

Any click handling needs the inverse of the transform. Keep it as one function and reuse it everywhere.

function toWorld(clientX, clientY) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: (clientX - rect.left - view.x) / view.scale,
    y: (clientY - rect.top  - view.y) / view.scale,
  };
}

Hit testing then compares world coordinates against your shape data, since the canvas itself keeps no shapes to ask.

Keep the tolerance in screen pixels rather than world units. A five pixel grab radius divided by the scale stays five pixels to the reader at every zoom level, which is what a pointer actually needs.

const tol = 5 / view.scale;
const hit = nodes.find(n => Math.hypot(n.x - p.x, n.y - p.y) < n.r + tol);

Things that should not scale

Some elements need to stay the same size on screen no matter the zoom. Labels, grid lines, handles.

ctx.lineWidth = 1 / view.scale;      // one screen pixel at any zoom
ctx.font = (12 / view.scale) + 'px system-ui';

Dividing by the scale cancels the transform for that property. For text, resetting the transform and drawing labels in screen space is often cleaner than fighting the division.

ctx.setTransform(1, 0, 0, 1, 0, 0);
nodes.forEach(n => {
  const sx = n.x * view.scale + view.x;
  const sy = n.y * view.scale + view.y;
  ctx.fillText(n.label, sx, sy - 10);
});

Labels drawn this way stay legible at every zoom and never end up mirrored or stretched, which is a risk once non uniform scaling enters the code.

Pinch zoom on touch

Two pointers, and the same anchoring maths applied to the midpoint between them.

const pointers = new Map();
let startDist = 0, startScale = 1;

canvas.addEventListener('pointerdown', e => pointers.set(e.pointerId, e));
canvas.addEventListener('pointerup',   e => pointers.delete(e.pointerId));

canvas.addEventListener('pointermove', e => {
  if (!pointers.has(e.pointerId)) return;
  pointers.set(e.pointerId, e);
  if (pointers.size !== 2) return;
  const [a, b] = [...pointers.values()];
  const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
  if (!startDist) { startDist = dist; startScale = view.scale; return; }
  view.scale = startScale * (dist / startDist);
  render();
});

Reset startDist to zero when a pointer lifts, or the next pinch begins from a stale reference and the scene jumps.

Keeping the scene in view

Unlimited panning lets a reader lose the drawing entirely, with no indication of which direction to drag back. Clamp the offset against the scene bounds.

function clamp() {
  const w = sceneWidth * view.scale;
  const h = sceneHeight * view.scale;
  const margin = 80;
  view.x = Math.min(margin, Math.max(canvas.clientWidth  - w - margin, view.x));
  view.y = Math.min(margin, Math.max(canvas.clientHeight - h - margin, view.y));
}

Call it after every pan and zoom, before render. The margin leaves a little slack so the edges do not feel glued to the frame.

Keeping it responsive

  • Redraw inside requestAnimationFrame, one frame at most per event burst.
  • Clamp the scale, as in the wheel handler above. Unbounded zoom produces coordinates large enough to lose precision.
  • Set a keyboard route too. Plus and minus keys, arrow keys for pan, and a key to reset.
  • Add a reset control. Two numbers back to their defaults and a redraw.
A canvas with zoom in, zoom out and reset buttons in the corner, and the current scale shown as a percentage.
A canvas with zoom in, zoom out and reset buttons in the corner, and the current scale shown as a percentage.

Common failures and what causes them

Symptom Cause
Old drawing smears across the view Transform not reset before clearRect
Page scrolls while zooming Wheel listener registered as passive
Drag stops when leaving the canvas No setPointerCapture
Nothing moves on a phone Missing touch-action: none
Zoom drifts away from the cursor Scaling around the origin instead of the pointer
Lines thicken as you zoom in Line width not divided by the scale

Each of these has a single line fix, and each one is invisible until someone other than you uses the page.

Sharing the result

A zoomable canvas cannot be sent as a screenshot without losing the only thing that makes it useful. It needs to be a live page.

Paste the HTML into a NOS document. The scripts run, so the reader can zoom and pan the same way you do, and the share link opens it in one click.

That is the same route as any other interactive chart or dashboard. If you also need a fixed picture of one view, HTML to image captures the rendered page.

Questions people ask

Why does the canvas go blurry when I zoom?

Because the zoom is being done with a CSS transform, which scales the finished bitmap. The pixels are already fixed. Apply the scale to the drawing context and redraw instead, and every line is regenerated at the new size.

How do I zoom toward the mouse pointer?

Convert the pointer position to world coordinates before changing the scale, change the scale, then adjust the offset so that same world point lands back under the pointer. Four lines, and it is the difference between usable and annoying.

Why does the page scroll when I zoom the canvas?

The wheel event still reaches the page. Call preventDefault in the handler, and register the listener with passive set to false, otherwise the browser ignores the preventDefault call.

How do I reset the view?

Set scale back to 1 and the offset back to zero, then redraw. Because the whole view is two numbers and a translation, a reset button is three lines. That is the main reason to keep the state separate from the drawing code.

Keep reading