← Blog ·

How an infinite canvas works: the camera math behind pan and zoom

Every infinite-canvas app — Figma, tldraw, Excalidraw, Quickdraw — is built on one small idea: shapes live in an unbounded coordinate system called page space, and a camera decides which part of it your screen shows. Get the camera math right and pan, zoom, pinch, and zoom-to-fit all fall out of one transform. Get it wrong and your canvas drifts, jitters, or zooms toward the corner instead of the cursor.

This is the math Quickdraw uses. It fits in a page.

The camera is three numbers

camera = { x, y, z }   // z is zoom; x, y are the pan offset in page units

The transform between a point on screen and a point on the page:

const toPage = (sx, sy) => ({
  x: sx / camera.z - camera.x,
  y: sy / camera.z - camera.y,
})
const toScreen = (px, py) => ({
  x: (px + camera.x) * camera.z,
  y: (py + camera.y) * camera.z,
})

Everything below is just algebra on these two functions.

Rendering

Don’t transform your shapes — transform the canvas context once per frame and draw shapes at their page coordinates:

ctx.setTransform(dpr * camera.z, 0, 0, dpr * camera.z,
                 dpr * camera.z * camera.x, dpr * camera.z * camera.y)
drawShapes(ctx)   // page coordinates, no per-shape math

(dpr is devicePixelRatio — bake it into the transform and your lines stay crisp on retina screens.)

Pan

A drag of (dx, dy) screen pixels moves the camera by that distance in page units, so divide by zoom:

camera.x += dx / camera.z
camera.y += dy / camera.z

Forgetting the division is the classic bug: panning feels right at 100% zoom, then twice as fast at 50%.

Zoom at the cursor

The rule: the page point under the cursor must stay under the cursor. Solve for the new camera position from that constraint:

function zoomAt(sx, sy, nextZ) {
  const p = toPage(sx, sy)        // before
  camera.z = nextZ
  camera.x = sx / nextZ - p.x     // after: same p under (sx, sy)
  camera.y = sy / nextZ - p.y
}

Wheel zoom is then one line — exponential, so each notch feels equal:

zoomAt(e.clientX, e.clientY, camera.z * Math.exp(-e.deltaY * 0.0025))

Clamp z to something like [0.05, 8] and you’re done.

Pinch

A two-finger pinch is a zoom and a pan at once. Track the midpoint and distance between the touches; each frame, apply the distance ratio as zoom at the midpoint, then the midpoint’s movement as a pan. Because both operations are expressed through the same camera, they compose without drift — there’s no accumulated error, since every frame recomputes from the two current touch points.

Zoom to fit

Compute the bounding box of all shapes in page space, then pick the zoom that fits it with a margin and center it:

const z = Math.min((vw - pad * 2) / bounds.w, (vh - pad * 2) / bounds.h)
camera.z = clamp(z, MIN_Z, 1)   // never zoom *in* past 100% to fit
camera.x = (vw / camera.z - bounds.w) / 2 - bounds.x
camera.y = (vh / camera.z - bounds.h) / 2 - bounds.y

The part people forget: hit-testing

Clicks arrive in screen space; shapes live in page space. Convert the pointer with toPage once at the top of your event handler and do all hit-testing in page coordinates. Mixing spaces — testing a page-space shape against a screen-space point — is the source of most “selection stops working when zoomed” bugs.

See it running

All of this is implemented in Quickdraw’s editor, MIT-licensed — editor.js has the camera, shapes.js the hit-testing. If you’d rather not build the rest of the whiteboard (undo, selection, styles, export…), that’s the point of the SDK:

npm install @quickdrawjs/core