Infinite HTML canvas with zoom and pan

Sandro Maglione

Sandro Maglione

Web development

Every drawing, whiteboard, or diagram app out there implemented a way to have an "Infinite canvas". These apps simulate an infinite amount of space by using math to update the coordinates of the canvas content.

I recently worked on a project that required exactly this functionality. I made some research and implemented an infinite canvas using javascript (typescript 💁🏼‍♂️) and simple math.

Here is how it works, and the code that makes it work 👇


Infinite <canvas>

The strategy to make the canvas "infinite" is to map its real size and coordinates on the screen to a new "unlimited" set of coordinates.

By default we set the canvas width and height to match the available size of the screen:

this.canvas.width = document.body.clientWidth;
this.canvas.height = document.body.clientHeight;

The real coordinates of the canvas start at 0,0 in the top-left corner. The x axis expands from the top-left to the top-right corner, while the y axis goes from the top-left to the bottom-left.

We are going to map these coordinates to a new set of coordinates to simulate an infinite canvas.

The new coordinates are defined by three parameters:

  • scale: used to zoom in and out the overall size of the canvas
  • offsetX: used to move the coordinates on the x axis
  • offsetY: used to move the coordinates on the y axis
Diagram showing canvas and infinite canvas coordinates

Convert coordinates

We define 4 helper functions that allow to convert from real to virtual coordinates and vice versa.

Converting from the real coordinates to the new virtual system requires to shift using offsetX (horizontal) and offsetY (vertical) and change the size using scale (zoom):

toVirtualX(xReal: number): number {
  return (xReal + this.#offsetX) * this.#scale;
}
 
toVirtualY(yReal: number): number {
  return (yReal + this.#offsetY) * this.#scale;
}
Diagram showing updated coordinates between canvas and infinite canvas

Converting back to the real coordinates is the inverse function:

toRealX(xVirtual: number): number {
  return xVirtual / this.#scale - this.#offsetX;
}
 
toRealY(yVirtual: number): number {
  return yVirtual / this.#scale - this.#offsetY;
}

We also define 2 more functions to compute the virtual height and width of the canvas based on scale:

virtualHeight(): number {
  return this.canvas.clientHeight / this.#scale;
}
 
virtualWidth(): number {
  return this.canvas.clientWidth / this.#scale;
}

canvas.clientHeight and canvas.clientWidth are the real canvas height and width on the screen.

Double touch to zoom and pan

Both the zooming and panning interactions require touching the screen with 2 fingers.

The input parameters required are the position on the screen of the current and "previous" touching event. Since we are listening for a double touch with 2 fingers, we are going to have 4 values in total:

  • prevTouch0 (prevTouch0X, prevTouch0Y): previous touch position of the first finger
  • prevTouch1 (prevTouch1X, prevTouch1Y): previous touch position of the second finger
  • touch0 (touch0X, touch0Y): current touch position of the first finger
  • touch1 (touch1X, touch1Y): current touch position of the second finger

We are going to add 2 event listeners on the <canvas> element:

  • touchstart: Register "previous" touch
  • touchmove: Update the current touch position

We are then going to extract the touches value from the event listener:

canvas.addEventListener("touchstart", (event) =>
  this.#onTouchStart(event.touches)
);
 
canvas.addEventListener("touchmove", (event) =>
  this.#onTouchMove(event.touches)
);

onTouchStart updates the value of the previous touch, checking also if we are touching with 2 fingers ("double"). onTouchStart then calls onTouchMove to start the interaction directly on the first click:

#onTouchStart(touches: TouchList) {
  if (touches.length == 1) {
    this.#touchMode = "single";
  } else if (touches.length >= 2) {
    this.#touchMode = "double";
  }
 
  this.#prevTouch[0] = touches[0];
  this.#prevTouch[1] = touches[1];
 
  this.#onTouchMove(touches);
}

onTouchMove is responsible to get the required input parameters and call zoom and pan:

#onTouchMove(touches: TouchList) {
  const touch0X = touches[0].pageX;
  const touch0Y = touches[0].pageY;
  const prevTouch0X = this.#prevTouch[0].pageX;
  const prevTouch0Y = this.#prevTouch[0].pageY;
 
  if (this.#touchMode === "single") {
    // Single touch (drawing 🎨)
  } else if (this.#touchMode === "double") {
    const touch1X = touches[1].pageX;
    const touch1Y = touches[1].pageY;
    const prevTouch1X = this.#prevTouch[1].pageX;
    const prevTouch1Y = this.#prevTouch[1].pageY;
 
    /* Call here `zoom`, `pan`, and `draw` (read next paragraphs 👇) */
  }
 
  /* Update previous touches for next interaction */
  this.#prevTouch[0] = touches[0];
  this.#prevTouch[1] = touches[1];
}

Zoom in and out

The canvas allows to zoom in and out using 2 fingers on mobile (pinch-zoom).

Zooming in and out requires doing some math to compute how much to change the scale value.

The scale amount consists in the relative distance between the current and previous touch.

The distance between two points in space can be computed using the Pythagorean theorem:

const distancePreviousTouches = Math.sqrt(
  Math.pow(prevTouch0X - prevTouch1X, 2) + Math.pow(prevTouch0Y - prevTouch1Y, 2)
);
 
const distanceCurrentTouches = Math.sqrt(
  Math.pow(touch0X - touch1X, 2) + Math.pow(touch0Y - touch1Y, 2)
);
Diagram showing zoom coordinates between canvas and infinite canvas

We can now compare the relative change in these two values to get the zoomAmount:

const zoomAmount = distanceCurrentTouches / distancePreviousTouches;

Finally, we update the scale value by multiplying by zoomAmount:

this.#scale *= zoomAmount;

Pan (moving the canvas)

The second interaction to allow an infinite canvas is panning: moving the canvas in an horizontal or vertical direction to simulate an infinite amount of space.

Panning requires the same inputs as before, since we are going to allow this interaction only when 2 fingers are touching the screen (a single touch will be reserved for drawing and clicks):

  • prevTouch0 (prevTouch0X, prevTouch0Y): previous touch position of the first finger
  • prevTouch1 (prevTouch1X, prevTouch1Y): previous touch position of the second finger
  • touch0 (touch0X, touch0Y): current touch position of the first finger
  • touch1 (touch1X, touch1Y): current touch position of the second finger

Panning requires to compute the middle point between the current and previous touch. The middle point is the difference between the two positions divided by 2:

const prevMidX = (prevTouch0X + prevTouch1X) / 2;
const prevMidY = (prevTouch0Y + prevTouch1Y) / 2;
 
const midX = (touch0X + touch1X) / 2;
const midY = (touch0Y + touch1Y) / 2;

The difference between the middle points is the distance on the vertical (Y) and horizontal (X) axis that the fingers moved between the two touches:

const panX = midX - prevMidX;
const panY = midY - prevMidY;
Diagram showing panning coordinates computation in infinite canvas

Now we can update offsetX and offsetY by adding these 2 values scaled based on scale:

this.#offsetX += panX / this.#scale;
this.#offsetY += panY / this.#scale;

Dividing by scale makes the change in the offset relative to the current zoom amount: if the zoom is high, the offset distance should be reduced (and vice versa).

Combining panning and zooming in one action

We must consider also another offset that happens when combining the zooming and panning action: the change in scale must be considered when updating offsetX and offsetY.

We start by defining a new value scaleAmount:

const scaleAmount = 1 - zoomAmount;

From here we compute the zoomRatio and the units zoomed from each edge of the screen:

const zoomRatioX = midX / this.canvas.clientWidth;
const zoomRatioY = midY / this.canvas.clientHeight;
 
// Amount zoomed from each edge of the screen
const unitsZoomedX = this.virtualWidth() * scaleAmount;
const unitsZoomedY = this.virtualHeight() * scaleAmount;

We then use these values to adjust the update on offsetX and offsetY:

const unitsAddLeft = unitsZoomedX * zoomRatioX;
const unitsAddTop = unitsZoomedY * zoomRatioY;
 
this.#offsetX += unitsAddLeft;
this.#offsetY += unitsAddTop;

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Drawing a grid

Now we can use scale, offsetX, and offsetY to draw anything on an infinite canvas.

In this example, we are going to add an infinite grid to the canvas.

A grid consist in a series of horizontal and vertical lines with a constant distance between each line.

The first parameter required is the distance between each line (cellSize). We allow to pass this value as input when drawing the grid.

The cellSize is the size of a cell in the grid when scale = 1. As we zoom in and out by changing the scale the cell size will change as well.

All we need to do now is to loop over the full width and height of the screen and draw a line at constant interval based on cellSize:

/* Full height and width (canvas size) */
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
 
/* Style of the grid line */
this.context.strokeStyle = "rgb(229,231,235)";
this.context.lineWidth = 1;
 
this.context.beginPath();
 
/* Vertical lines spanning the full width */
for (
  /* Start the first line based on offsetX and scale */
  let x = (this.#offsetX % this.cellSize) * this.#scale;
  x <= width;
  /* Cell size based on scale amount */
  x += this.cellSize * this.#scale
) {
  this.context.moveTo(x, 0);
  this.context.lineTo(x, height);
}
 
/* Horizontal lines spanning the full height */
for (
  /* Start the first line based on offsetY and scale */
  let y = (this.#offsetY % this.cellSize) * this.#scale;
  y <= height;
  /* Cell size based on scale amount */
  y += this.cellSize * this.#scale
) {
  this.context.moveTo(0, y);
  this.context.lineTo(width, y);
}
 
/* Draw the lines (path) on the canvas */
this.context.stroke();
Example of drawing a grid on an infinite canvas

Rendering cycle

We define a new draw() function to update the drawings in the canvas:

draw() {
  this.canvas.width = document.body.clientWidth;
  this.canvas.height = document.body.clientHeight;
  this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
 
  this.#drawGrid();
  // Define and execute here other drawings 🎨
}

It is important to draw clearRect to remove the previous drawings and update with the new ones. We need to call draw() every time we change scale, offsetX, offsetY, or every time the user draws something on the canvas.

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.