Building a Vanilla-JS Image Zoomer
Posted on January 2, 2017
One of my favorite ways of improving my knowledge of "vanilla" JavaScript and the browser's DOM API is to create complex UI components without utilizing any libraries or frameworks. It's a great way to familiarize myself with the methods available for DOM manipulation, event handling, and information retrieval. Along the way, I always pick up a few tips and tricks to add to my front end toolbelt. The technique fits in nicely with my philosophy that one of the best ways to learn in programming is to "re-invent the wheel".
In this post, I'm going to walk through implementing an image zoomer component. It's basically what you use on a site like Amazon to get a closer look at an image by hovering your mouse over a thumbnail. I'll be building a slightly simplified implementation based on this implementation from my UI Components project (code here). Along the way, I'll touch on topics like listening to mouse events, getting an element's position within the viewport, and calculating the position of the zoomed image.
Defining the Component
To keep things simple, we'll define our component as a JavaScript class whose
constructor takes an image element. In the real world, a class like this can be
hooked into a more declarative system for instantiating components. For example,
in my original implementation, I define an <image-zoomer>
custom element that
I can use for creating instances.
So to start off, here's our class:
class ImageZoomer {constructor(image) {this.image = image;}}
Setting up the DOM
Before we can start coding the logic to handle the behavior of the image zoomer, we need to modify our initialization logic to transform the DOM into the state we need it to be in. We'll need the following:
- Zoom Box: A
div
to represent the box that surrounds the cursor and outlines the zoomed portion of the image while the user hovers over the original image. We'll reference this element asthis.zoomBox
. - Zoom Container: A
div
to wrap the original image and the zoom box. Its job is to support positioning the zoom box over the image, so it should be the same size as the image. Well reference it asthis.zoomContainer
. - Zoomed Image: A copy of the original image that we'll allow to grow to its
full size so it can be "zoomed". For this effect to work well, the layout in
which the image zoomer component is used should display the original image at
a much smaller size than it's true size. We'll reference the zoomed image as
this.zoomedImage
. - Zoom Window: A
div
to contain the zoomed image. It should be smaller than the full size of the image to ensure that only a portion of the zoomed image is visible. We'll move the zoomed image around inside the zoom window as the user moves the mouse cursor over the original image. We'll reference the zoom window asthis.zoomWindow
.
Now that we know what DOM structure we'll need, let's add the logic to set it up:
class ImageZoomer {constructor(image) {let parentEl = image.parentNode;this.image = image;this.zoomedImage = image.cloneNode();this.zoomWindow = createZoomWindow(this.zoomedImage);this.zoomBox = createZoomBox();this.zoomContainer = createZoomContainer(image, this.zoomBox);parentEl.appendChild(this.zoomContainer);parentEl.appendChild(this.zoomWindow);}}function createZoomBox() {let zoomBox = document.createElement('div');zoomBox.classList.add('zoom-box');return zoomBox;}function createZoomContainer(image, zoomBox) {let zoomContainer = document.createElement('div');zoomContainer.classList.add('zoom-container');zoomContainer.appendChild(image);zoomContainer.appendChild(zoomBox);return zoomContainer;}function createZoomWindow(zoomedImage) {let zoomWindow = document.createElement('div');zoomWindow.classList.add('zoom-window');zoomedImage.setAttribute('aria-hidden', 'true');zoomWindow.appendChild(zoomedImage);return zoomWindow;}let image = document.querySelector('.image-zoomer-demo img');let zoomer = new ImageZoomer(image);
Say our initial HTML is the following:
<div class="image-zoomer-demo"><img src="..." alt="..."></div>
Executing the JS code will change the DOM to the following structure:
- div.image-zoomer-demo
- div.zoom-container
- img (the original image)
- div.zoom-box
- div.zoom-window
- img (the cloned
zoomedImage
for zooming)
- img (the cloned
- div.zoom-container
Here are a few important points to note about the code above:
- We take advantage of the browser's
document.createElement
API to create new elements like the zoom container, zoom box, and zoom window. - We use the
cloneNode
API to make a copy of the original image to use as our zoomed image. - We use the
classList
API for adding CSS classes to our new elements. - We use the
appendChild
API for adding elements to other elements to achieve our desired DOM structure.
These APIs are some of the handiest built-in browser APIs for manipulating the DOM. They may not be as elegant as jQuery's API, but they certainly get the job done and done cheaply.
Note also that the createZoomBox()
function is not yet complete, as we'll be
adding logic to properly size the zoom box in an upcoming section.
Setting up the Styles
Now that we have the JS to initialize our component state and DOM structure, let's shift our focus for a moment to styling. We want to apply styling that will:
- Size the original image smaller than the zoomed image, with the original image and the zoomed image laid out next to each other.
- Size the zoom window to be (roughly) a square.
- Ensure the zoomed image does not shrink inside the zoom window but also that the part of it that overflows the zoom window is not visible.
- Set the zoom box and the zoomed image up to be positioned dynamically.
- Keep the zoom box and zoomed image invisible until we want to show them.
Here's our styling. Note that I've written it with Sass to keep it concise.
.image-zoomer-demo {display: flex;margin: 0 auto;max-width: 600px;}.zoom-container {flex: 1;padding-right: 25px;position: relative;img {max-width: 100%;}}.zoom-box {backface-visibility: hidden; // Hack to activate GPU acceleration.border: 2px lightblue solid;left: 0;opacity: 0;position: absolute;top: 0;&.active {opacity: 1;}}.zoom-window {border: 1px #31708f solid;flex: 2;padding-bottom: calc(66.66% - 25px);overflow: hidden;position: relative;img {backface-visibility: hidden; // Hack to activate GPU acceleration.position: absolute;opacity: 0;}&.active img {opacity: 1;}}
Here are some important points to note about our styling code:
- We use Flexbox to lay out the zoom container and zoom window horizontally,
sizing the zoom container roughly one-third of the container's width and the
zoom window roughly two-thirds of the container's width. We add
max-width: 100%
to the original image in the zoom container so it doesn't grow larger than the container. - We use the padding hack to make the zoom window's height roughly equal to its width.
- We prevent the part of the zoomed image that exceeds the bounds of the zoom
window from showing by setting
overflow: hidden
on the zoom window. - We absolutely position the zoom box and the zoomed image, as well as relatively position their containers, so they will render at the top left corners of their containers before we position them dynamically.
- We set
opacity: 0
on the zoom box and zoomed image so they won't be visible initially. We also add styling foractive
classes that, when added to the zoom box or zoomed image, will reveal them by setting opacity to 1.
A quick note on performance: We could have used display: none
instead of
opacity: 0
to hide the zoom box and the zoomed image. But opacity is a better
choice for performance because it is one of the things the browser can use the
GPU to animate, a process known as hardware acceleration. Using the GPU leads to
smoother animation as the browser does not have to re-paint the areas of the
screen that are animating. Instead, the browser paints the elements to be
animated to "layers" that are uploaded to the GPU, which it can then animate.
Note that the browser doesn't always create new layers for the GPU when you want
it to, so you can force it to do so by setting certain properties like
backface-visibility
(in the future, will-change
will be the
official mechanism). For more on this, check out "High Performance
Animations" on HTML5 Rocks.
Sizing the Zoom Box
The next step in building the image zoomer is to size the zoom box. We want to size it so it covers the same area on the original image that is visible in the zoom window. This sizing must be done dynamically in JavaScript so we can support variable widths for our layout (e.g. if it's placed somewhere smaller than its current max width of 600 pixels). Here's the relevant update to the code:
class ImageZoomer {constructor(image) {// Same code as before...sizeZoomBox(this.zoomBox, image, this.zoomWindow, this.zoomedImage);}}function sizeZoomBox(zoomBox, image, zoomWindow, zoomedImage) {let widthPercentage = zoomWindow.clientWidth / zoomedImage.clientWidth;let heightPercentage = zoomWindow.clientHeight / zoomedImage.clientHeight;zoomBox.style.width = Math.round(image.clientWidth * widthPercentage) + 'px';zoomBox.style.height = Math.round(image.clientHeight * heightPercentage) + 'px';}
Here we add a new sizeZoomBox()
function which we call at the end of the
constructor
. The function will be accessing the computed dimensions of the
original image, the zoom window, and the zoomed image in order to calculate the
zoom box's size. Therefore, it must be called after these elements have been
added to the DOM. The function works as follows:
- Calculate the percentage of the zoomed image's width that fits in the zoom window: zoom window's width / zoomed image's width.
- Calculate the percentage of the zoomed image's height that fits in the zoom window: zoom window's height / zoomed image's height.
- Set the zoom box's width to the original image's width times the width percentage calculated in the first step (rounded).
- Set the zoom box's height to the original image's height times the height percentage calculated in the second step (rounded).
This gives us dimensions for the zoom box that are proportional to the zoomed
image inside the zoom window. As far as DOM APIs go, we use the clientWidth
and clientHeight
properties to get the width and height of our elements in
pixels, and we use the style
property to set the zoom box's width and height.
One additional consideration would be to re-run this operation if the user
changes the size of the browser window. I'll leave that as an exercise for the
reader.
A quick performance-related note: The code in its current state will cause a
forced synchronous layout. What this means is that when we ask for the
clientWidth
and clientHeight
of our elements, the browser will have to pause
our JS and do layout calculations before it can give us those values. For our
purposes this probably isn't a big deal, but too much of this kind of thing can
cause layout thrashing. In my original implementation, I
avoid the synchronous layout by lazily creating the zoom box element when the
user first mouses over the image.
Moving the Zoom Box
Now that we have everything laid out, let's get something interesting happening: moving the zoom box to follow the mouse cursor while it's over the original image.
Setting Up Event Listeners
First, we need to set up our event listeners. After initialization, we'll start listening for the mouse to enter the original image or the zoom box (since it's sitting on top of the image). Once the mouse enters, we'll show the zoom box, stop listening for mouse enter, and start listening for mouse move events so we can move the zoom box in response.
The following code additions will set up our listener logic:
class ImageZoomer {constructor(image) {// Same code as before...this.activate = this.activate.bind(this);this.handleMouseMove = this.handleMouseMove.bind(this);this.listenForMouseEnter();}activate() {this.zoomBox.classList.add('active');this.zoomWindow.classList.add('active');this.listenForMouseMove();}handleMouseMove() {// TODO}listenForMouseEnter() {let { image, zoomBox } = this;document.body.removeEventListener('mousemove', this.handleMouseMove);image.addEventListener('mouseenter', this.activate);zoomBox.addEventListener('mouseenter', this.activate);}listenForMouseMove() {let { image, zoomBox } = this;image.removeEventListener('mouseenter', this.activate);zoomBox.removeEventListener('mouseenter', this.activate);document.body.addEventListener('mousemove', this.handleMouseMove);}}
This gets us as far as showing the zoom box when the user mouses over the original image and switching from listening to mouse enter to mouse move. Important points to note:
- In the constructor we bind methods that will be passed to the browser's
addEventListener
andremoveEventListener
APIs so they don't lose their context (this
value) when invoked. - We always remove our
mousemove
listener when adding ourmouseenter
listeners, as that will be necessary once we implement the code to handle the mouse leaving the image. For the first call, the browser doesn't care if we try to remove a listener that hasn't been added. - The
activate
method adds theactive
class to the zoom box and zoom window so the zoom box and the image in the zoom window will become visible.
Handling Mouse Move Events
The handleMouseMove
method will be responsible for responding to mouse move
events and either moving the zoom box or hiding it if the cursor has left the
image. Moving the zoom box will require calculating a position that places it
around the cursor.
Let's implement handleMouseMove
:
class ImageZoomer {constructor(image) {// Same code as before...this.imageBounds = toDocumentBounds(image.getBoundingClientRect());}// ...handleMouseMove(event) {if (this.isMoveScheduled) {return;}window.requestAnimationFrame(() => {if (isWithinImage(this.imageBounds, event)) {this.updateUI(event.pageX, event.pageY);} else {this.deactivate();}this.isMoveScheduled = false;});this.isMoveScheduled = true;}// ...}// ...function isWithinImage(imageBounds, event) {let { bottom, left, right, top } = imageBounds;let { pageX, pageY } = event;return pageX > left && pageX < right && pageY > top && pageY < bottom;}// ...function toDocumentBounds(bounds) {let { scrollX, scrollY } = window;let { bottom, height, left, right, top, width } = bounds;return {bottom: bottom + scrollY,height,left: left + scrollX,right: right + scrollX,top: top + scrollY,width};}
Here's how the handleMouseMove
code works:
When an event comes in, it calls the browser's
requestAnimationFrame
API to schedule the work of
responding to the event. requestAnimationFrame
will invoke the callback passed
to it at the optimal time before the browser paints the next frame. This way,
we can either move the box or hide it at the best point in time.
The method also utilizes an isMoveScheduled
instance variable so the work is
not scheduled more than once per frame. When an event comes in and no work is
scheduled, it schedules the work and sets the instance variable to true. If
another event comes in before the scheduled work has run, it will check the
instance variable and not schedule duplicate work. Once requestAnimationFrame
fires and the work is performed, the instance variable is set back to false so
that more work can be scheduled. Without this check, duplicate work could be
scheduled if the user moves the mouse over the image very quickly.
In the requestAnimationFrame
callback, it checks to see if the mouse event
reports the cursor as within the image or not using the new isWithinImage
utility function. It passes the imageBounds
, which we've added a calculation
for in the constructor using the
getBoundingClientRect
API method. This method,
while not the most fun to type, is extremely useful in that it returns to you an
object containing info on the element's position (bottom, left, right, top) and
dimensions (height and width). One caveat of this method is that it gives you
the element's position relative to the browser's viewport, not the beginning of
the document. So if you want to know where the element is relative to the
beginning of the entire document, you need to account for whether and by how
much the page is scrolled. We do this using another utility function,
toDocumentBounds
, which uses window.scrollX
and window.scrollY
to
determine how much the page is scrolled and adds those values to the appropriate
data points for the element's position.
isWithinImage
then uses then pageX
and pageY
properties of the mouse
event, which tell where in the document the cursor was when the event occurred,
to determine if the mouse has left the image or not. handleMouseMove
uses the
result to either call the updateUI
method or the deactivate
method, which
we'll implement in the next couple of sections.
Changing Zoom Box Position
Okay, after much preliminary work, we're finally ready to change the zoom box's
position. To do this, we'll implement the updateUI
method. For now, it'll only
move the zoom box but later we'll also have it move the zoomed image. Here's the
code we need to move the zoom box:
class ImageZoomer {constructor(image) {// Same code as before...this.zoomBoxBounds = toDocumentBounds(this.zoomBox.getBoundingClientRect());}// ...updateUI(mouseX, mouseY) {let { imageBounds, zoomBox, zoomBoxBounds } = this;let { x: xOffset, y: yOffset } = getZoomBoxOffset(mouseX, mouseY, zoomBoxBounds, imageBounds);zoomBox.style.transform = `translate(${xOffset}px, ${yOffset}px)`;}}// ...function getZoomBoxOffset(mouseX, mouseY, zoomBoxBounds, imageBounds) {let x = mouseX - (zoomBoxBounds.width / 2);let y = mouseY - (zoomBoxBounds.height / 2);x -= zoomBoxBounds.left;y -= zoomBoxBounds.top;return {x: Math.round(x), y: Math.round(y)};}
With those changes in place, the zoom box now appears when the mouse hovers over the original image, surrounds the cursor, and follows it as it moves over the image. Here are the important points to note about this code:
- We capture the position of the zoom box relative to the document as
this.zoomBoxBounds
using the same technique we used for the image bounds. updateUI
invokes the new utility functiongetZoomBoxOffset
to figure out how much we need to move the zoom box relative to its original position at the top left corner of the zoom container.- The
getZoomBoxOffset
utility calculates the zoom box offset by first figuring out the position relative to the document that would place the zoom box around the cursor, subtracting from the X and Y values of the mouse position half of the width and half of the height of the zoom box (so the cursor will be in the middle). It then subtracts the original position of the zoom box relative to the document from those values to yield the amount to move the zoom box in the X and Y directions. updateUI
then moves the zoom box by applying a translate transform to it using the offset values fromgetZoomBoxOffset
. The translate transform supports hardware acceleration via the GPU the same way as opacity, which we discussed earlier. This means the browser can move the zoom box without doing any re-painting, which is great for performance. This is in contrast to properties liketop
andleft
that do cause re-paints.
Hiding the Zoom Box
Next we need to hide the zoom box when the mouse leaves the original image.
Additionally, we must switch our event listening back from mouse move to mouse
enter so we'll know the next time the user hovers over the image. The logic for
this is simple compared to what we've already implemented. We just need to
implement the deactivate
method to remove the active
class from the zoom box
(we'll also remove it from the zoom window to get that out of the way):
class ImageZoomer {// ...deactivate() {this.zoomBox.classList.remove('active');this.zoomWindow.classList.remove('active');this.listenForMouseEnter();}// ...}
Containing the Zoom Box in the Image
At this point, we show, move, and hide the zoom box based on mouse movement. The
final concern to address with the zoom box is keeping it from moving outside the
original image when the mouse gets close to the image's edge. In general, the
zoom box should surround the cursor with it in the middle. But once it hits the
edge of the image it should move no further in that direction. Thankfully, this
only requires a small update to the code that calculates the zoom box offset and
the creation of one more utility. Here's the updated getZoomBoxOffset
function
and a new containNum
utility:
// ...function containNum(num, lowerBound, upperBound) {if (num < lowerBound) {return lowerBound;}if (num > upperBound) {return upperBound;}return num;}// ...function getZoomBoxOffset(mouseX, mouseY, zoomBoxBounds, imageBounds) {let x = mouseX - (zoomBoxBounds.width / 2);let y = mouseY - (zoomBoxBounds.height / 2);x = containNum(x, imageBounds.left, imageBounds.right - zoomBoxBounds.width);y = containNum(y, imageBounds.top, imageBounds.bottom - zoomBoxBounds.height);x -= zoomBoxBounds.left;y -= zoomBoxBounds.top;return {x: Math.round(x), y: Math.round(y)};}// ...
The new lines in getZoomBoxOffset
are the ones that call the new containNum
utility. containNum
simply takes a number, a lower bound number, and an upper
bound number, and returns either the number or one of the bounds if the number
is outside the bounds. getZoomBoxOffset
uses it to adjust the x and y offsets
for the zoom box so they can't move the zoom box outside the image. For example,
it adjusts the X offset by passing the current X value, the left bound of the
image, and the right bound of the image minus the width of the zoom box (to
account for the horizontal position of the zoom box being set relative to its
left edge, not its center).
Moving the Zoomed Image
The last major feature of the image zoomer for us to implement is moving the zoomed image to match the zoom box. Given that we implemented event listeners and a UI update method for the zoom box, adding support for moving the zoomed image won't be too complex. Here's the code update:
class ImageZoomer {// ...moveZoomedImage(xPercent, yPercent) {let { zoomedImage } = this;let xOffset = Math.round(zoomedImage.clientWidth * xPercent) * -1;let yOffset = Math.round(zoomedImage.clientHeight * yPercent) * -1;zoomedImage.style.transform = `translate(${xOffset}px, ${yOffset}px)`;}updateUI(mouseX, mouseY) {// Same code as before...this.moveZoomedImage(xOffset / imageBounds.width, yOffset / imageBounds.height);}}
The updateUI
method now calls a new moveZoomedImage
method, passing it the
percentages of how much to move the zoomed image in the horizontal and vertical
directions. It calculates these percentages by dividing the offset applied to
the zoom box by the corresponding dimension of the original image (e.g.
horizontal zoom box offset / width of original image).
moveZoomedImage
then uses those percentages to move the zoomed image. For each
direction, it calculates the offset by multiplying the percentage by the
corresponding dimension of the zoomed image (e.g. X percentage * width of zoomed
image). It then multiplies that offset by -1 to account for the zoomed image
needing to move the same relative distance as the zoom box, but in the opposite
direction. Finally, it sets a translate transform to the zoomed image to
performantly change its position.
Conclusion
There you have it: a working image zoomer component. If you want to play around with it more, I've posted the code from this walkthrough on CodePen. Let's recap what we've covered:
- Setting up a class to define the state and behavior for our component.
- Setting up the initial DOM structure for the image zoomer using browser APIs
like
document.createElement
,classList
, andappendChild
. - Writing styles that lay out the original image and zoomed image and position them appropriately for dynamic movement.
- Sizing and moving the zoom box.
- Setting up event listeners for mouse movement.
- Showing, moving, and hiding the zoom box and zoomed image using the
hardware-accelerated CSS properties
opacity
andtransform
.
If you liked this post, note that I plan to write in the future on implementing tooltips and modals with vanilla JS. If you'd like to discuss this further, feel free to reach out to me on Twitter at @WillHPower.