How to Implement Zoom Image in React

Bernard Bado
April 10, 2022
How to Implement Zoom Image in React
When you use affiliate links in this article, We earn a small comission. Thank you!

If you have a website that could benefit from a zoom image feature, you'll most likely need to have a component that can do such a thing. The zoom feature may sound complicated at first, but the implementation is not difficult at all (if you choose the right tools).

Luckily for you, we have an article that will guide you through the process of adding the zoom image feature to your site. We'll show you different ways of zooming to an image.

And in case you want to implement this functionality yourself. We'll walk you through the steps needed to implement, and integrate this feature into your React application.

We have a lot to cover, so without any further ado, let's get started.

How to Zoom Image in React

There is a very simple way to zoom an image in React. And to be honest, you don't even need React to perform this kind of zoom effect.

This approach uses CSS transform property to scale up the image. Using this technique with a combination of overflow will achieve desired zoom effect.

The transform CSS property lets you rotate, scale, skew or translate an element. It modifies the coordinate space of the CSS visual formatting model. (source: MDN)

The React code for this component will look like the following.

App.jsx
import "./styles.css";

const App = () => {
  return (
    <div className="app">
      <div className="img-container">
        <img
          src="https://thumbs.dreamstime.com/b/footbridge-sea-beach-meditation-journey-calm-hormone-sunset-sea-yoga-footbridge-sea-beach-meditation-journey-calm-hormone-128381503.jpg"
          alt="meditation"
        />
      </div>
    </div>
  );
};

export default App;

We just need to apply the corresponding styling.

styles.css
.app {
  display: grid;
  place-items: center;
  height: 100vh;
}

.img-container {
  overflow: hidden;
}

.img-container img {
  width: 450px;
  transition: transform 0.3s;
}

.img-container img:hover {
  transform: scale(1.5);
}

And the result will look like the following.

As you can see, this approach is quick and simple.

However, there are some negatives to it. Users can't control where to zoom or move inside a zoomed image. It may work for certain situations, but for others, it may not.

That's why we're gonna take it one step further.

Building Zoom Image Component in React

In this section, we're gonna build a reusable zoom image component in React. It'll be much more sophisticated than the example in the previous section. But it will also have more code complexity.

note

The source code for this example is available on GitHub.

We could spend precious minutes trying to describe the component we're going to build. But it's much more easier to showcase it in action.

info

This example will use Canvas API to display the image and perform all the zooming effects.

If you're not familiar with canvas, there is no reason to worry. It's basically just an interface for drawing inside HTML.

The Canvas API provides a means for drawing graphics via JavaScript and the HTML canvas element. Among other things, it can be used for animation, game graphics, data visualization, photo manipulation, and real-time video processing. It largely focuses on 2D graphics. (source: MDN)

Building a Component

Let's start by creating a simple component that will display the image on our canvas. In addition, it should also redraw the canvas every time we change the image.

This logic can be handled by the following implementation.

Building Component Skeleton
import React, { useRef, useMemo, useEffect } from "react";

const ZoomImage = ({ image }) => {
  const canvasRef = useRef(null);
  const containerRef = useRef(null);
  const observer = useRef(null);
  const background = useMemo(() => new Image(), [image]);

  // This will make the component responsive.
  // Container will always adapt the size, and the canvas wil be redrawn
  useEffect(() => {
    observer.current = new ResizeObserver((entries) => {
      entries.forEach(({ target }) => {
        const { width, height } = background;
        // If width of the container is smaller than image, scale image down
        if (target.clientWidth < width) {
          // Calculate scale
          const scale = target.clientWidth / width;

          // Redraw image
          canvasRef.current.width = width * scale;
          canvasRef.current.height = height * scale;
          canvasRef.current
            .getContext("2d")
            .drawImage(background, 0, 0, width * scale, height * scale);
        }
      });
    });
    observer.current.observe(containerRef.current);

    return () => observer.current.unobserve(containerRef.current);
  }, []);

  useEffect(() => {
    background.src = image;

    if (canvasRef.current) {
      background.onload = () => {
        // Get the image dimensions
        const { width, height } = background;
        canvasRef.current.width = width;
        canvasRef.current.height = height;

        // Draw the image
        canvasRef.current.getContext("2d").drawImage(background, 0, 0);
      };
    }
  }, [background]);

  return (
    <div ref={containerRef}>
      <canvas ref={canvasRef} />
    </div>
  );
};

export default ZoomImage;
info

As you can see, it's already a lot of stuff going on. We need to make sure that the image is displayed properly, and we're also making sure that it's scaled properly based on the browser window.

Now, it's time to let users zoom in and out of the image.

Adding Zoom Effect

To add a zoom effect, we need to listen for wheel scrolling events and update the zoom scale accordingly.

When the zoom scale changes, we need to redraw our image with the correct scaling.

This is how our component will change.

Adding Zoom
import React, { useRef, useMemo, useEffect, useState } from "react";

const SCROLL_SENSITIVITY = 0.0005;
const MAX_ZOOM = 5;
const MIN_ZOOM = 0.1;

const ZoomImage = ({ image }) => {
  const [zoom, setZoom] = useState(1);

  const canvasRef = useRef(null);
  const containerRef = useRef(null);
  const observer = useRef(null);
  const background = useMemo(() => new Image(), [image]);

  // This function returns the number between min and max values.
  // For example clamp(3,5,7) wil lreturn 5
  const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

  const handleWheel = (event) => {
    const { deltaY } = event;
    setZoom((zoom) =>
      clamp(zoom + deltaY * SCROLL_SENSITIVITY * -1, MIN_ZOOM, MAX_ZOOM)
    );
  };

  const draw = () => {
    if (canvasRef.current) {
      const { width, height } = background;
      const context = canvasRef.current.getContext("2d");

      // Set canvas dimensions
      canvasRef.current.width = width;
      canvasRef.current.height = height;

      // Clear canvas and scale it based on current zoom
      context.clearRect(0, 0, width, height);
      context.scale(zoom, zoom);

      // Draw image
      context.drawImage(background, 0, 0);
    }
  };

  useEffect(() => {
    draw();
  }, [zoom]);

  return (
    <div ref={containerRef}>
      <canvas onWheel={handleWheel} ref={canvasRef} />
    </div>
  );
};

export default ZoomImage;

We've added quite a bit of code already. Now is a good time to check on our component. Just to see how it's looking.

As you can see, it's possible to zoom in and out, but the zoomed image is not visible at all. However, this can be easily fixed by zooming the image to its center.

Zooming Image to The Center

To zoom the image correctly to the center, all we need to do is modify our draw function a bit. We don't want to bore you with all the Math calculations that are happening. But the important part is the following.

Calculating x and y
  const x = (context.canvas.width / zoom - background.width) / 2;
  const y = (context.canvas.height / zoom - background.height) / 2;

And this is how draw function will look after the change.

Modified draw function
const draw = () => {
  if (canvasRef.current) {
    const { width, height } = canvasRef.current;
    const context = canvasRef.current.getContext("2d");

    // Set canvas dimensions
    canvasRef.current.width = width;
    canvasRef.current.height = height;

    // Clear canvas and scale it
    context.scale(zoom, zoom);
    context.clearRect(0, 0, width, height);

    // Make sure we're zooming to the center
    const x = (context.canvas.width / zoom - background.width) / 2;
    const y = (context.canvas.height / zoom - background.height) / 2;

    // Draw image
    context.drawImage(background, x, y);
  }
};

After this change, our component is starting to look usable. Just see for yourself.

At this point, all that's left is to add the ability to drag the photo.

Controlling Zoom Position

To control the position, we need to keep track of the offset. And we also need to know in what direction the user is dragging the image.

To achieve this, we'll need to listen for 3 additional events on canvas element.

  • onMouseDown
  • onMouseUp
  • onMouseMove

Using these events, we'll need to determine if the user is panning the image or not. We'll also need to calculate the direction in which the user is panning.

On top of that, we'll also need to modify our draw function so it also accounts for the image offset.

Adding image panning
import React, { useRef, useMemo, useEffect, useState } from "react";

const ZoomImage = ({ image }) => {
  const [offset, setOffset] = useState({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  const [draggind, setDragging] = useState(false);

  const touch = useRef({ x: 0, y: 0 });

  const handleMouseMove = (event) => {
    if (draggind) {
      const { x, y } = touch.current;
      const { clientX, clientY } = event;
      // Calculate where the user is panning the image
      setOffset({
        x: offset.x + (x - clientX),
        y: offset.y + (y - clientY),
      });
      // Update the last position where user clicked
      touch.current = { x: clientX, y: clientY };
    }
  };

  const handleMouseDown = (event) => {
    const { clientX, clientY } = event;
    // Store the last position where user clicked
    touch.current = { x: clientX, y: clientY };
    setDragging(true);
  };

  const handleMouseUp = () => setDragging(false);

  const draw = () => {
    if (canvasRef.current) {
      const { width, height } = canvasRef.current;
      const context = canvasRef.current.getContext("2d");

      // Set canvas dimensions
      canvasRef.current.width = width;
      canvasRef.current.height = height;

      // Clear canvas, scale it, and move it based on the offset
      context.translate(-offset.x, -offset.y);
      context.scale(zoom, zoom);
      context.clearRect(0, 0, width, height);

      // Make sure we're zooming to the center
      const x = (context.canvas.width / zoom - background.width) / 2;
      const y = (context.canvas.height / zoom - background.height) / 2;

      // Draw image
      context.drawImage(background, x, y);
    }
  };

  useEffect(() => {
    draw();
  }, [zoom, offset]);

  return (
    <div ref={containerRef}>
      <canvas
        onMouseDown={handleMouseDown}
        onMouseUp={handleMouseUp}
        onWheel={handleWheel}
        onMouseMove={handleMouseMove}
        ref={canvasRef}
      />
    </div>
  );
};

export default ZoomImage;

This is how the code will look at the end.

ZoomImage.jsx
import React, { useRef, useMemo, useEffect, useState } from "react";

const SCROLL_SENSITIVITY = 0.0005;
const MAX_ZOOM = 5;
const MIN_ZOOM = 0.1;

const ZoomImage = ({ image }) => {
  const [offset, setOffset] = useState({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  const [draggind, setDragging] = useState(false);

  const touch = useRef({ x: 0, y: 0 });
  const canvasRef = useRef(null);
  const containerRef = useRef(null);
  const observer = useRef(null);
  const background = useMemo(() => new Image(), [image]);

  const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

  const handleWheel = (event) => {
    const { deltaY } = event;
    if (!draggind) {
      setZoom((zoom) =>
        clamp(zoom + deltaY * SCROLL_SENSITIVITY * -1, MIN_ZOOM, MAX_ZOOM)
      );
    }
  };

  const handleMouseMove = (event) => {
    if (draggind) {
      const { x, y } = touch.current;
      const { clientX, clientY } = event;
      setOffset({
        x: offset.x + (x - clientX),
        y: offset.y + (y - clientY),
      });
      touch.current = { x: clientX, y: clientY };
    }
  };

  const handleMouseDown = (event) => {
    const { clientX, clientY } = event;
    touch.current = { x: clientX, y: clientY };
    setDragging(true);
  };

  const handleMouseUp = () => setDragging(false);

  const draw = () => {
    if (canvasRef.current) {
      const { width, height } = canvasRef.current;
      const context = canvasRef.current.getContext("2d");

      // Set canvas dimensions
      canvasRef.current.width = width;
      canvasRef.current.height = height;

      // Clear canvas and scale it
      context.translate(-offset.x, -offset.y);
      context.scale(zoom, zoom);
      context.clearRect(0, 0, width, height);

      // Make sure we're zooming to the center
      const x = (context.canvas.width / zoom - background.width) / 2;
      const y = (context.canvas.height / zoom - background.height) / 2;

      // Draw image
      context.drawImage(background, x, y);
    }
  };

  useEffect(() => {
    observer.current = new ResizeObserver((entries) => {
      entries.forEach(({ target }) => {
        const { width, height } = background;
        // If width of the container is smaller than image, scale image down
        if (target.clientWidth < width) {
          // Calculate scale
          const scale = target.clientWidth / width;

          // Redraw image
          canvasRef.current.width = width * scale;
          canvasRef.current.height = height * scale;
          canvasRef.current
            .getContext("2d")
            .drawImage(background, 0, 0, width * scale, height * scale);
        }
      });
    });
    observer.current.observe(containerRef.current);

    return () => observer.current.unobserve(containerRef.current);
  }, []);

  useEffect(() => {
    background.src = image;

    if (canvasRef.current) {
      background.onload = () => {
        // Get the image dimensions
        const { width, height } = background;
        canvasRef.current.width = width;
        canvasRef.current.height = height;

        // Set image as background
        canvasRef.current.getContext("2d").drawImage(background, 0, 0);
      };
    }
  }, [background]);

  useEffect(() => {
    draw();
  }, [zoom, offset]);

  return (
    <div ref={containerRef}>
      <canvas
        onMouseDown={handleMouseDown}
        onMouseUp={handleMouseUp}
        onWheel={handleWheel}
        onMouseMove={handleMouseMove}
        ref={canvasRef}
      />
    </div>
  );
};

export default ZoomImage;

Final Component

Believe it or not, that's actually the final state of our component.

Of course, there is plenty of improvement that can be made to this component, but it's already working well enough and covers a lot of functionality.

Concluding Thoughts

The zoomable image component is definitely a useful component. And if you need to display a lot of images on your website, it can only benefit from using this kind of component.

As we learned in this article, it's not rocket science to build a zoomable component in React. There are different approaches to handling the zooming feature. Some of them are simple, others are more complex, but they provide a better user experience.

In this article, you learned how to build your own zoomable image component in React. And on top of that, we also showcased interesting tips and tricks that are related to working with images.

The only thing that's left to do is to pick your preferred solution and start implementing it on your website.