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:
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:
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):
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:
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.1
Let’s use the useScale
hook in an example:
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.
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.
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.
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!
And to consume the context to get the grid effect in the prior example:
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!
Footnotes
-
You may be wondering why I’m manually attaching the
wheel
event instead of using a ReactonWheel
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 calleduseEventListener
that is just responsible for manually setting up and tearing down an event listener on a DOM node attached to the given ref. ↩