# 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
_8export type CanvasState {_8 // The pan state_8 offset: {x: number, y: number}_8 // The zoom state_8 scale: number_8}_8_8export const CanvasContext = React.createContext<CanvasState>({} as any)
SomeCanvasComponent.tsx
_4function 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
_62import {_62 MouseEvent as SyntheticMouseEvent,_62 useCallback,_62 useRef,_62 useState_62} from 'react'_62_62type Point = {x: number; y: number}_62const 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 */_62export 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
_9export 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
_51import {RefObject, useState} from 'react'_51import useEventListener from './useEventListener'_51_51type ScaleOpts = {_51 direction: 'up' | 'down'_51 interval: number_51}_51_51const MIN_SCALE = 0.5_51const MAX_SCALE = 3_51_51/**_51 * Listen for wheel events on the given element ref and update the reported_51 * scale state, accordingly._51 */_51export 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
_25import {RefObject, useEffect} from 'react'_25_25export 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
_10export 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
_17export 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 = (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 _34export 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)$

Keep in mind that the math operations here are on points, so $+$ is summing the $x$ and $y$ 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 / lastScale$

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

$newMouse = mousePos / scale$

Next, we calculate how much the mouse has moved relative to our canvas as a result of the scaling by subtracting the $newMouse$ value from the $lastMouse$ 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 = lastMouse - newMouse$

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

$adjOffset = 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
_64export 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 _25export type CanvasState {_25 offset: Point_25 buffer: Point_25 scale: number_25}_25_25export const CanvasContext = React.createContext<CanvasState>({} as any)_25_25export 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 _18export 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!