Jonathan Clem

Building a Pannable, Zoomable Canvas in React

A quick note, since I've gotten a few questions about this. The code for this post is not on GitHub. Feel free to use the code in this post under the MIT license:

Copyright (c) 2020 Jonathan Clem <jonathan@jclem.net>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Recently, I was tinkering on a side-project where I wanted to build a sort of canvas of very large dimensions that I could zoom in on and pan around, similar to zooming and panning around in a map application.

In this post, I'm going to detail how I built this in React and what challenges I had to overcome in doing so. The components I was building were only intended to be used in a desktop browser, so on touch-enabled devices, the examples have been replaced with illustrative video clips.

In my first attempts at building this pannable and zoomable canvas, I bound the canvas's pan and zoom state directly to the canvas's DOM element itself. Ultimately, this caused a lot problems, because there were certain elements visually laid out on the canvas that I either did not want to scale, or did not want to pan (such as some user interface elements on the canvas).

Ultimately, I decided to try an approach that decoupled the desired pan and zoom state entirely from the canvas component itself. Instead of binding the pan and zoom state to the canvas, I wanted to create a React context that reported the user's desired pan and zoom state, but didn't actually manipulate the DOM in any way.

Here's a simplified explanation of what I wanted in code:

CanvasContext.tsx
Copy

_8
export type CanvasState {
_8
// The pan state
_8
offset: {x: number, y: number}
_8
// The zoom state
_8
scale: number
_8
}
_8
_8
export const CanvasContext = React.createContext<CanvasState>({} as any)

SomeCanvasComponent.tsx
Copy

_4
function SomeCanvasComponent() {
_4
const {state} = useContext(CanvasContext)
_4
return <div>The desired user zoom level is {state.scale}.</div>
_4
}

Here, you can see that CanvasContext doesn't do any direct manipulation of the DOM. It just tells SomeCanvasComponent that the user wants the scale to be at some value, and leaves it up to that component to actually reflect that desired state.

My first step was to implement the panning state. To do this, I implemented a usePan hook that that tracked the user panning around a component.

Essentially, usePan is a hook that returns a pan offset state and a function. The function should be called whenever the user starts a pan on the target element (usually a mousedown event). On each mousemove event until a mouseup occurs and we remove our event listeners, we calculate the delta between the last observed mouse position on mousemove and the current event's mouse position. Then, we apply that delta to our offset state.

One quick detail—you may wonder why the mousemove and mouseup event listeners in this hook are bound to document and not to the target element specified by the user. This is because we want to ensure that any mouse movement by the user whatsoever while panning, even if not over the target element itself, still pans the canvas. For example, on pages like this blog post where the canvas is contained within another bounding element, we don't want panning to stop just because the user's mouse happened to leave the bounding element.

Here is the usePan hook (note that you'll see the Point type and ORIGIN constant referenced in other places in this blog post):

usePan.ts
Copy

_62
import {
_62
MouseEvent as SyntheticMouseEvent,
_62
useCallback,
_62
useRef,
_62
useState
_62
} from 'react'
_62
_62
type Point = {x: number; y: number}
_62
const ORIGIN = Object.freeze({x: 0, y: 0})
_62
_62
/**
_62
* Track the user's intended panning offset by listening to `mousemove` events
_62
* once the user has started panning.
_62
*/
_62
export default function usePan(): [Point, (e: SyntheticMouseEvent) => void] {
_62
const [panState, setPanState] = useState<Point>(ORIGIN)
_62
_62
// Track the last observed mouse position on pan.
_62
const lastPointRef = useRef(ORIGIN)
_62
_62
const pan = useCallback((e: MouseEvent) => {
_62
const lastPoint = lastPointRef.current
_62
const point = {x: e.pageX, y: e.pageY}
_62
lastPointRef.current = point
_62
_62
// Find the delta between the last mouse position on `mousemove` and the
_62
// current mouse position.
_62
//
_62
// Then, apply that delta to the current pan offset and set that as the new
_62
// state.
_62
setPanState(panState => {
_62
const delta = {
_62
x: lastPoint.x - point.x,
_62
y: lastPoint.y - point.y
_62
}
_62
const offset = {
_62
x: panState.x + delta.x,
_62
y: panState.y + delta.y
_62
}
_62
_62
return offset
_62
})
_62
}, [])
_62
_62
// Tear down listeners.
_62
const endPan = useCallback(() => {
_62
document.removeEventListener('mousemove', pan)
_62
document.removeEventListener('mouseup', endPan)
_62
}, [pan])
_62
_62
// Set up listeners.
_62
const startPan = useCallback(
_62
(e: SyntheticMouseEvent) => {
_62
document.addEventListener('mousemove', pan)
_62
document.addEventListener('mouseup', endPan)
_62
lastPointRef.current = {x: e.pageX, y: e.pageY}
_62
},
_62
[pan, endPan]
_62
)
_62
_62
return [panState, startPan]
_62
}

Let's use the usePan hook in a simple example that will just show us how much we've panned around total. Note that in this and other examples, I'm omitting styling for clarity:

UsePanExample.tsx
Copy

_9
export const UsePanExample = () => {
_9
const [offset, startPan] = usePan()
_9
_9
return (
_9
<div onMouseDown={startPan}>
_9
<span>{JSON.stringify(offset)}</span>
_9
</div>
_9
)
_9
}

If you click on this example and drag around, you'll see a persistent measure of how far you've dragged both horizontally and vertically.

As you can see, this isn't really panning an element since neither it nor our viewport are moving, but later we'll see how these values can be used to simulate panning in various ways depending on our needs.

Now that I had the basics of panning state down, I needed to tackle scaling. For scaling, I decided to implement a hook called useScale. Much like usePan, it doesn't actually do any scaling or zooming. Instead, it listens on certain events and reports back what it thinks the user intends for the current scale level to be.

useScale.ts
Copy

_51
import {RefObject, useState} from 'react'
_51
import useEventListener from './useEventListener'
_51
_51
type ScaleOpts = {
_51
direction: 'up' | 'down'
_51
interval: number
_51
}
_51
_51
const MIN_SCALE = 0.5
_51
const MAX_SCALE = 3
_51
_51
/**
_51
* Listen for `wheel` events on the given element ref and update the reported
_51
* scale state, accordingly.
_51
*/
_51
export default function useScale(ref: RefObject<HTMLElement | null>) {
_51
const [scale, setScale] = useState(1)
_51
_51
const updateScale = ({direction, interval}: ScaleOpts) => {
_51
setScale(currentScale => {
_51
let scale: number
_51
_51
// Adjust up to or down to the maximum or minimum scale levels by `interval`.
_51
if (direction === 'up' && currentScale + interval < MAX_SCALE) {
_51
scale = currentScale + interval
_51
} else if (direction === 'up') {
_51
scale = MAX_SCALE
_51
} else if (direction === 'down' && currentScale - interval > MIN_SCALE) {
_51
scale = currentScale - interval
_51
} else if (direction === 'down') {
_51
scale = MIN_SCALE
_51
} else {
_51
scale = currentScale
_51
}
_51
_51
return scale
_51
})
_51
}
_51
_51
// Set up an event listener such that on `wheel`, we call `updateScale`.
_51
useEventListener(ref, 'wheel', e => {
_51
e.preventDefault()
_51
_51
updateScale({
_51
direction: e.deltaY > 0 ? 'up' : 'down',
_51
interval: 0.1
_51
})
_51
})
_51
_51
return scale
_51
}

Note: You may be wondering why I'm manually attaching the `wheel` event instead of using a React `onWheel` listener. React has some surpising behavior and some bugs related to how it handles wheel events on components, so I am avoiding them by manually attaching an event listener. In this case I'm using a convenience hook called `useEventListener` that is just responsible for manually setting up and tearing down an event listener on a DOM node attached to the given ref:
useEventListener.ts
Copy

_25
import {RefObject, useEffect} from 'react'
_25
_25
export default function useEventListener<
_25
K extends keyof GlobalEventHandlersEventMap
_25
>(
_25
ref: RefObject<HTMLElement | null>,
_25
event: K,
_25
listener: (event: GlobalEventHandlersEventMap[K]) => void,
_25
options?: boolean | AddEventListenerOptions
_25
) {
_25
useEffect(() => {
_25
const node = ref.current
_25
_25
if (!node) {
_25
return
_25
}
_25
_25
const listenerWrapper = ((e: GlobalEventHandlersEventMap[K]) =>
_25
listener(e)) as EventListener
_25
_25
node.addEventListener(event, listenerWrapper, options)
_25
_25
return () => node.removeEventListener(event, listenerWrapper)
_25
}, [ref, event, listener, options])
_25
}

Let's use the useScale hook in an example:

UseScaleExample.tsx
Copy

_10
export const UseScaleExample = () => {
_10
const ref = useRef<HTMLDivElement | null>(null)
_10
const scale = useScale(ref)
_10
_10
return (
_10
<div ref={ref}>
_10
<span>{scale}</span>
_10
</div>
_10
)
_10
}

If you scroll up and down inside the example's bounding box, you should see the scale value update.

Now that we have our usePan and useScale hooks, how do we actually create a pannable, zoomable canvas? Or rather, how do we create the illusion of a pannable, zoomable canvas? For my particular use case, I knew that I could create the illusion of panning and scaling by manipulating the canvas's background offset for panning, and the canvas's scale for scaling, rather than actually trying to move the element itself around.

UsePanScaleExample.tsx
Copy

_17
export const UsePanScaleExample = () => {
_17
const [offset, startPan] = usePan()
_17
const ref = useRef<HTMLDivElement | null>(null)
_17
const scale = useScale(ref)
_17
_17
return (
_17
<div ref={ref} onMouseDown={startPan}>
_17
<div
_17
style={{
_17
backgroundImage: 'url(/grid.svg)',
_17
transform: `scale(${scale})`,
_17
backgroundPosition: `${-offset.x}px ${-offset.y}px`
_17
}}
_17
></div>
_17
</div>
_17
)
_17
}

We're on our way, but not quite there! In this example, panning seems to work fine! The background position updates according to the reported offset from usePan. Scaling kind of works, but unfortunately, as we scale the element down, we end up exposing a buffer between its edges and its bounding box. It doesn't really feel like we're zooming in and out on the canvas so much as it feels like we're zooming in and out on our tiny window into the canvas.

In order to solve this, I decided to use calculate a buffer based on the bounding box around the canvas itself. This buffer represents horizontal and vertical space we need to fill between the bounding box and what would normally be the edge of the zoomed out canvas. We can calculate this buffer for each side every time the scale changes using the formula xbuf=(boundingWidthboundingWidth/scale)/2xbuf = (boundingWidth - boundingWidth / scale) / 2. In plain English, the buffer to apply to each horizontal side is equal to one half of the width of the bounding element minus the width of the bounding element divided by the current scale. The same holds true for for the vertical sides, only one would use the bounding element's height.

BufferExample.tsx
Copy

_34
export const BufferExample = () => {
_34
const [buffer, setBuffer] = useState(pointUtils.ORIGIN)
_34
const [offset, startPan] = usePan()
_34
const ref = useRef<HTMLDivElement | null>(null)
_34
const scale = useScale(ref)
_34
_34
useLayoutEffect(() => {
_34
const height = ref.current?.clientHeight ?? 0
_34
const width = ref.current?.clientWidth ?? 0
_34
_34
// This is the application of the above formula!
_34
setBuffer({
_34
x: (width - width / scale) / 2,
_34
y: (height - height / scale) / 2
_34
})
_34
}, [scale, setBuffer])
_34
_34
return (
_34
<div ref={ref} onMouseDown={startPan} style={{position: 'relative'}}>
_34
<div
_34
style={{
_34
backgroundImage: 'url(/grid.svg)',
_34
transform: `scale(${scale})`,
_34
backgroundPosition: `${-offset.x}px ${-offset.y}px`,
_34
position: 'absolute',
_34
bottom: buffer.y,
_34
left: buffer.x,
_34
right: buffer.x,
_34
top: buffer.y
_34
}}
_34
></div>
_34
</div>
_34
)
_34
}

In this example, we absolutely position the actual canvas background DOM node within the bounding element and use the calculated buffer values to set the top, right, bottom, and left positions. Essentially, if the user has scaled out to 0.5 and the bounding element is 100 pixels wide, we know that we need to set the left and right positions out 50 pixels further than usual, so we set them each to -50px.

Now, we have a canvas that feels infinite in size (barring integer overflow) that we can pan and zoom on. I was proud of this accomplishment, but something still didn't feel quite right about it. You'll notice that when you zoom, the focal point is always the top-left corner of the bounding container—you're always going to be zooming in on that corner, and then would have to pan to your destination (or place it in the corner before zooming). I have seen other implementations solve this by always just zooming into the exact center of the canvas, where the user may be somewhat more likely to have placed the area they are trying to focus in on. To me, this still felt like it was putting a lot of burden on the user to position things in the exact right place before zooming.

Instead, I wanted to find the point to zoom in on dynamically, under the assumption that the mouse pointer is probably pointing at the thing the user is trying to focus on. In order to accomplish this, I made several important changes to the component.

First, I implemented a hook called useMousePos. I won't show its source code here, but essentially it just returns a ref whose value is the last known position of the user's mouse pointer, based on listening to mousemove and wheel events on the canvas's bounding container.

Then, I implemented a hook called useLast. This hook maintains a reference to the previous value passed to it and returns that last known value. This is just an easy way to track the last known value of scale and offset and the current value. We'll get to why this is necessary shortly.

Finally, calculating the adjusted offset based on the user's mouse position as the user scales is where things get really tricky. In order to store this adjusted value, I create a container ref for it called adjustedOffset. If on any given render the scale has not changed (lastScale === scale), we set the adjusted offset to be the sum of the current adjusted offset and the delta between the current and last non-adjusted offset, scaled according to our current scale value. In other words, when the scale has not changed:

adjOffset=adjOffset+(offsetDelta/scale)adjOffset = adjOffset + (offsetDelta / scale)

Keep in mind that the math operations here are on points, so ++ is summing the xx and yy values of each point. We "scale" a point by dividing by the scale value (so if the user pans by 10 pixels but scale is 0.5, we adjust that delta value to 10 / 0.5, yielding 20 pixels at our current scale).

When we want to get the adjusted offset when the scale has changed, we need to do things a little differently, because we now want to ensure that the focal point of the change in scale is the user's mouse pointer. In other words, as we scale (as long as the user is not panning at the same time), the point on the canvas directly under the user's mouse pointer should not change. First, we get the mouse position adjusted according to the last known scale value:

lastMouse=mousePos/lastScalelastMouse = mousePos / lastScale

Then, we get the mouse position adjusted according to the current scale value:

newMouse=mousePos/scalenewMouse = mousePos / scale

Next, we calculate how much the mouse has moved relative to our canvas as a result of the scaling by subtracting the newMousenewMouse value from the lastMouselastMouse value. This will tell us how much we need to adjust our offset by in order to compensate for the change in relative position of the mouse pointer to the canvas as a result of the scaling:

mouseOffset=lastMousenewMousemouseOffset = lastMouse - newMouse

Finally, we set apply this offset by adding the mouseOffsetmouseOffset we calculated to the current adjusted offset value:

adjOffset=adjOffset+mouseOffsetadjOffset = adjOffset + mouseOffset

Rather than using our offset provided by usePan, we now use our new adjusted offset, which maintains our pan offset relative to the user's mouse position as they zoom in and out.

TrackingExample.tsx
Copy

_64
export const TrackingExample = () => {
_64
const [buffer, setBuffer] = useState(pointUtils.ORIGIN)
_64
const ref = useRef<HTMLDivElement | null>(null)
_64
const [offset, startPan] = usePan()
_64
const scale = useScale(ref)
_64
_64
// Track the mouse position.
_64
const mousePosRef = useMousePos(ref)
_64
_64
// Track the last known offset and scale.
_64
const lastOffset = useLast(offset)
_64
const lastScale = useLast(scale)
_64
_64
// Calculate the delta between the current and last offset—how far the user has panned.
_64
const delta = pointUtils.diff(offset, lastOffset)
_64
_64
// Since scale also affects offset, we track our own "real" offset that's
_64
// changed by both panning and zooming.
_64
const adjustedOffset = useRef(pointUtils.sum(offset, delta))
_64
_64
if (lastScale === scale) {
_64
// No change in scale—just apply the delta between the last and new offset
_64
// to the adjusted offset.
_64
adjustedOffset.current = pointUtils.sum(
_64
adjustedOffset.current,
_64
pointUtils.scale(delta, scale)
_64
)
_64
} else {
_64
// The scale has changed—adjust the offset to compensate for the change in
_64
// relative position of the pointer to the canvas.
_64
const lastMouse = pointUtils.scale(mousePosRef.current, lastScale)
_64
const newMouse = pointUtils.scale(mousePosRef.current, scale)
_64
const mouseOffset = pointUtils.diff(lastMouse, newMouse)
_64
adjustedOffset.current = pointUtils.sum(adjustedOffset.current, mouseOffset)
_64
}
_64
_64
useLayoutEffect(() => {
_64
const height = ref.current?.clientHeight ?? 0
_64
const width = ref.current?.clientWidth ?? 0
_64
_64
setBuffer({
_64
x: (width - width / scale) / 2,
_64
y: (height - height / scale) / 2
_64
})
_64
}, [scale, setBuffer])
_64
_64
return (
_64
<div ref={ref} onMouseDown={startPan} style={{position: 'relative'}}>
_64
<div
_64
style={{
_64
backgroundImage: 'url(/grid.svg)',
_64
transform: `scale(${scale})`,
_64
backgroundPosition: `${-adjustedOffset.current.x}px ${-adjustedOffset
_64
.current.y}px`,
_64
position: 'absolute',
_64
bottom: buffer.y,
_64
left: buffer.x,
_64
right: buffer.x,
_64
top: buffer.y
_64
}}
_64
></div>
_64
</div>
_64
)
_64
}

In this example, notice that as you zoom in and out, the focal point always remains the mouse cursor, even if you pan and zoom simultaneously, or move the mouse as you zoom.

With this final addition, I had something that felt very natural to use, much like a maps application. I was surprised at the complexity required to build a good user experience for something that seems relatively simple—surely, there are simplifications that could be made here and probably a few bugs in the React code, as well.

Now, I was ready to wrap this component up into a context. Thankfully, that was pretty easy!

CanvasContext.tsx
Copy

_25
export type CanvasState {
_25
offset: Point
_25
buffer: Point
_25
scale: number
_25
}
_25
_25
export const CanvasContext = React.createContext<CanvasState>({} as any)
_25
_25
export default function CanvasProvider(props: PropsWithChildren<unknown>) {
_25
// Insert here all of the hooks from the previous example!
_25
_25
return (
_25
<CanvasContext.Provider
_25
value={{
_25
offset: adjustedOffset.current,
_25
scale,
_25
buffer
_25
}}
_25
>
_25
<div ref={ref} onMouseDown={startPan} style={{position: 'relative'}}>
_25
{props.children}
_25
</div>
_25
</CanvasContext.Provider>
_25
)
_25
}

And to consume the context to get the grid effect in the prior example:

GridBackground.tsx
Copy

_18
export default function GridBackground() {
_18
const {offset, buffer, scale} = useContext(CanvasContext)
_18
_18
return (
_18
<div
_18
style={{
_18
backgroundImage: 'url(/grid.svg)',
_18
transform: `scale(${scale})`,
_18
backgroundPosition: `${-offset.x}px ${-offset.y}px`,
_18
position: 'absolute',
_18
bottom: buffer.y,
_18
left: buffer.x,
_18
right: buffer.x,
_18
top: buffer.y
_18
}}
_18
></div>
_18
)
_18
}

Now that we have our basic canvas container and context set up, there's a lot more we can do just by consuming the desired state of the canvas view. In a future blog post, I hope to show how I've also implemented a feature where cards can be added to this canvas by command-clicking at the desired position.

Thanks for reading!