Mouse-Over Image Zoom Effect in React using Custom Hooks

Arjun Palakkazhi
12 min readJan 9, 2023

--

Result — Mouse Over Image Zoom Effect

Embedding interactive utilities in the user interface continue to be an important aspect of modern web designing. One common utility I noticed in a lot of e-commerce websites is the “Mouse Over Image Zooming”. Using this utility we can move the cursor over the product image and an enlarged (zoomed) version of the area around the cursor is displayed in a separate box. This is really helpful for the users to inspect areas of interest in an object without actually having to open the image and zoom in manually. In this tutorial, I will show how to implement this utility into a react application.

View Demo

If you want to skip over the steps and get right into the finished app. The final code is available at https://github.com/a2-coder/tutorial-mouseover-zoom

Planning

Let’s try to define the problems at hand and then derive an approach based on possible solutions.

Problems

  1. How to get the position (coordinates) of the mouse pointer while hovering over the image?
  2. How to extract the part of the image around a given position?
  3. How to display the extracted part of the image in a separate box?
  4. How to show the cursor of the bounds over the image?

Solutions

  1. We can get the position of the mouse pointer by listening to the mouseover event on the image.
  2. We can compute the bounds around a given position using the formula:

We can change the value of the radius to increase or decrease the zoom level.

3. We can extract and display the part of the image inside the bounds of a canvas using the drawImage method of CanvasRenderingContext2D.

4. We can set the position of the container div of the image to relative and add a new div with position: absolute. Now, we can set the left, right, width & height values of this div equal to the bounds and it will follow the cursor and also mark the area of zoom.

Approach

  • We will define a custom hook — useMouseOverZoom to handle the entire zoom effect.
  • We will define another custom hook — useMouse which takes an element ref as a parameter to get the position of the mouse pointer relative to the element. We will use this in the useMouseOverZoom hook to extract the mouse position.
  • We will use an image to display the main image and a canvas to display the cropped & zoomed part of the image.
  • We can create references to this image and canvas using the useRef hook and pass this to our useMouseOverZoom hook.
  • Inside useMouseOverZoom, we will use the useMouse hook to get the position of the cursor relative to the image.
  • We can also use a div to create the cursor and pass its ref to the useMouseOverZoom hook to control its position.
  • We will compute the cursor bounds using a radius which can be passed as another parameter to the useMouseOverZoom hook.
  • We can set the position of the cursor using these computed bounds.
  • We will retrieve the 2D rendering context of the canvas and call the drawImage method with the bounds to render the part of the image into the canvas.

Initializing the Project

I have set up a starter template for this tutorial with all the UI elements already added so that I can go straight to the matter at hand and focus on how the mouse-over zoom effect can be applied. To give you an idea, in the starter template I have:

  • Using pnpm as package manager.
  • Used Vite + React + TypeScript starter template.
  • Added Tailwind CSS for styling.
  • Created a basic layout as you can see in the main image.
  • Included a stock image from Pexels for our subject.
  • Added a canvas element to draw the zoomed portion of the image.

Clone the starter

git clone https://github.com/a2-coder/tutorial-mouseover-zoom.git -b initial-setup
cd tutorial-mouseover-zoom

Install Dependencies

pnpm install
# or if you are using npm
npm install

Start the dev server

pnpm run dev
# or if you are using npm
npm run dev

If you are using npm just replace pnpm with npm.

If you are not familiar with TypeScript, you can just ignore the type annotations and go ahead.

In case you are using your own JavaScript project, create .js/.jsx files instead of .ts/.tsx.

Step 1: Setting up the Custom Hook

In this step, we will create a placeholder hook function useMouseOverZoom which takes two parameters i.e., the references of image and canvas, and call this in our main App component.

Note: We will be adding more parameters to this hook as we progress.

Create a new file hook.ts in the src directory and add the following lines:

import React from "react";

export function useMouseOverZoom(
source: React.RefObject<HTMLImageElement>,
target: React.RefObject<HTMLCanvasElement>
) {
// TODO: implement
}

Open the src/App.tsx file and make the changes

import profileSrc from "./assets/profile.png";
import GithubIcon from "./icons/Github";
import MediumIcon from "./icons/Medium";
import image from "./assets/image.jpg";
import { useRef } from "react"; // new
import { useMouseOverZoom } from "./hooks"; // new

function App() {
const source = useRef<HTMLImageElement>(null); // new
const target = useRef<HTMLCanvasElement>(null); // new
// call the custom hook
useMouseOverZoom(source, target); // new
return (
<div className="w-screen h-screen bg-gradient-to-tr from-indigo-200 to-indigo-50 relative">
<div className="grid grid-cols-12 gap-6 h-full">
<div className="col-span-12 md:col-span-6 px-12 md:px-24 flex items-center relative">
{/* ...other contents */ }
</div>
<div className="col-span-12 md:col-span-4 md:col-start-9 border-t-8 md:border-t-0 md:border-l-8 border-indigo-500 relative z-10">
<img ref={source} src={image} className="w-full h-full bg-gray-100 cursor-crosshair object-cover" />
<canvas ref={target} className="absolute pointer-events-none bottom-full translate-y-1/2 left-1/2 -translate-x-1/2 md:translate-y-0 md:translate-x-0 md:bottom-16 md:-left-16 border-8 border-indigo-500 w-32 h-32 z-10 bg-gray-200" />
</div>
</div>
</div>
);
}
export default App;

In the above step, I:

  1. Imported the useRef hook and created two references “source” and “target”. Then we added the source ref to our main image element and the target ref to our canvas element. We will be extracting parts of the source and rendering it to the target.
  2. Imported the useMouseOverZoom custom hook we just defined and called it inside our App component passing the source and target as arguments.

In the coming steps, we will add functions to the useMouseOverZoom hook to complete the utility.

The complete code till this step is available at https://github.com/a2-coder/tutorial-mouseover-zoom/tree/steps/1-setting-up-the-custom-hook

Step 2: Capturing Mouse Position

We will now create the useMouse hook to capture the mouse position. This hook will take the reference of an HTML element and attach a mousemove handler which will update a reactive state with the mouse position relative to the element.

We will now create the useMouse hook to capture the mouse position. This hook will take the reference of an HTML element and attach a mousemove handler which will update a reactive state with the mouse position relative to the element.

Add the useMouse hook function to hooks.ts file:

function useMouse(ref: React.RefObject<HTMLElement>) {
const [mouse, setMouse] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
useEffect(() => {
if (ref.current) {
const handleMouseMove = (e: MouseEvent) => {
// get mouse position relative to ref
const rect = ref.current?.getBoundingClientRect();
if (rect) {
setMouse({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
ref.current.addEventListener("mousemove", handleMouseMove);
return () => {
ref.current!.removeEventListener("mousemove", handleMouseMove)
};
}
});
return mouse;
}

Use this hook in our useMouseOverZoom hook and pass the source parameter to capture the mouse pointer relative to the image element.

export function useMouseOverZoom(
source: React.RefObject<HTMLImageElement>,
target: React.RefObject<HTMLCanvasElement>
) {
// CAPTURE MOUSE POSITION
const { x, y } = useMouse(source);
}

The complete code till this step is available at: https://github.com/a2-coder/tutorial-mouseover-zoom/tree/steps/2-capturing-mouse-position

Step 3: Computing the Zoom Bounds

We will use the formula mentioned above to compute the bounds based on the cursor position.

Inside the useMouseOverZoom hook, add the following code after the useMouse call:

// Compute the part of the image to zoom based on mouse position
const zoomBounds = useMemo(() => {
return {
left: x - radius,
top: y - radius,
width: radius * 2,
height: radius * 2,
}
}, [x, y]);
// The `radius` value is passed to the hook as a parameter

Now, to make things look better, we will add a cursor to show the area of the image we are zooming. To do so, we will add a div element with absolute positioning and pass its ref to the hook. Inside the hook, we will set the position of this cursor element based on the zoomBounds we just calculated.

// move the cursor to the mouse position
useEffect(() => {
if (cursor.current) {
const { left, top, width, height } = zoomBounds;
cursor.current.style.left = `${left}px`;
cursor.current.style.top = `${top}px`;
cursor.current.style.width = `${width}px`;
cursor.current.style.height = `${height}px`;
}
}, [zoomBounds]);

Finally, our useMouseOver hook looks like this:

export function useMouseOverZoom(
source: React.RefObject<HTMLImageElement>,
target: React.RefObject<HTMLCanvasElement>,
cursor: React.RefObject<HTMLElement>,
radius = 25
) {
// Capture Mouse position
const { x, y } = useMouse(source);
// Compute the part of the image to zoom based on mouse position
const zoomBounds = useMemo(() => {
return {
left: x - radius,
top: y - radius,
width: radius * 2,
height: radius * 2,
}
}, [x, y]);
// move the cursor to the mouse position
useEffect(() => {
if (cursor.current) {
const { left, top, width, height } = zoomBounds;
cursor.current.style.left = `${left}px`;
cursor.current.style.top = `${top}px`;
cursor.current.style.width = `${width}px`;
cursor.current.style.height = `${height}px`;
}
}, [zoomBounds]);
}

And our App.tsx file:

import profileSrc from "./assets/profile.png";
import GithubIcon from "./icons/Github";
import MediumIcon from "./icons/Medium";
import image from "./assets/image.jpg";
import { useRef } from "react";
import { useMouseOverZoom } from "./hooks";

function App() {

const source = useRef<HTMLImageElement>(null);
const target = useRef<HTMLCanvasElement>(null);
const cursor = useRef<HTMLDivElement>(null); // new

// call the custom hook
useMouseOverZoom(source, target, cursor);

return (
<div className="w-screen h-screen bg-gradient-to-tr from-indigo-200 to-indigo-50 relative">
<div className="grid grid-cols-12 gap-6 h-full">
<div className="col-span-12 md:col-span-6 px-12 md:px-24 flex items-center relative">
{/* The left side content */}
</div>
<div className="col-span-12 md:col-span-4 md:col-start-9 border-t-8 md:border-t-0 md:border-l-8 border-indigo-500 relative z-10">
<img ref={source} src={image} className="w-full h-full bg-gray-100 cursor-crosshair object-cover" />
<div ref={cursor} className="border border-sky-500 absolute pointer-events-none" /> {/* NEW */}
<canvas ref={target} className="absolute pointer-events-none bottom-full translate-y-1/2 left-1/2 -translate-x-1/2 md:translate-y-0 md:translate-x-0 md:bottom-16 md:-left-16 border-8 border-indigo-500 w-32 h-32 z-10 bg-gray-200" />{" "}
</div>
</div>
</div>
);
}

export default App;

We added the pointer-events-none class to avoid the element interacting with the mouse cursor and causing a glitchy interface.

Now we will get this nice square shape following the cursor around:

The complete code till this step is available at: https://github.com/a2-coder/tutorial-mouseover-zoom/tree/steps/3-computing-the-zoom-bounds

Step 4: Rendering the Zoomed Portion

Now, we have all the details needed to extract the part of the image and render it to the canvas. We will use the drawImage method to render the zoom bounds of the image to the entire space of the canvas.

ctx.drawImage(left, top, width, height, 0, 0, canvasWidth, canvasHeight)

Here, left, top, width, and height are our zoom bounds, and canvasWidth and canvasHeight are the width and height of the canvas element.

But, there is a problem with this. If we use the above statement to render the image, we will find that the canvas is not showing the correct part of the image represented by the zoom bounds. This is because the image is scaled on our webpage. In other words, the actual height and width of the image are different from the width and height of the img element used to render the image on the webpage.

To tackle this problem, we will calculate the scaling factor or the ratio between the actual width and the rendered width of the image. This can be calculated as:

const imageRatio = source.current.naturalWidth / source.current.width;

Now we will multiply our bounds with this ratio and use it to draw the image on to the canvas.

ctx.drawImage(
source.current,
left * imageRatio,
top * imageRatio,
width * imageRatio,
height * imageRatio,
0,
0,
target.current.width,
target.current.height
);

The ctx variable contains the value from calling the getContext("2d") method on the canvas: const ctx = target.current.getContext("2d");

The final version of our hook will be:

export function useMouseOverZoom(
source: React.RefObject<HTMLImageElement>,
target: React.RefObject<HTMLCanvasElement>,
cursor: React.RefObject<HTMLElement>,
radius = 25
) {
// Capture Mouse position
const { x, y } = useMouse(source);
// Compute the part of the image to zoom based on mouse position
const zoomBounds = useMemo(() => {
return {
left: x - radius,
top: y - radius,
width: radius * 2,
height: radius * 2,
}
}, [x, y]);
// move the cursor to the mouse position
useEffect(() => {
if (cursor.current) {
const { left, top, width, height } = zoomBounds;
cursor.current.style.left = `${left}px`;
cursor.current.style.top = `${top}px`;
cursor.current.style.width = `${width}px`;
cursor.current.style.height = `${height}px`;
}
}, [zoomBounds]);
// draw the zoomed image on the canvas
useEffect(() => {
if (source.current && target.current) {
const { left, top, width, height } = zoomBounds;
const ctx = target.current.getContext("2d");
const imageRatio = source.current.naturalWidth / source.current.width;
if (ctx) {
ctx.drawImage(
source.current,
left * imageRatio,
top * imageRatio,
width * imageRatio,
height * imageRatio,
0,
0,
target.current.width,
target.current.height
);
}
}
}, [zoomBounds])
}

I also increased the size of the output canvas since I felt it was too small:

<canvas ref={target} className="absolute pointer-events-none bottom-full translate-y-1/2 left-1/2 -translate-x-1/2 md:translate-y-0 md:translate-x-0 md:bottom-16 md:-left-48 border-8 border-indigo-500 w-64 h-64 z-10 bg-gray-200" />

Voila!! We have our mouse-over zoom effect magic now added to the webpage.

The complete code till this step is available at: https://github.com/a2-coder/tutorial-mouseover-zoom/tree/steps/4-render-the-zoomed-portion

Step 5: Cleanup

We still have a few things to take care of:

  1. Hide the bounds cursor when not hovering over the image.
  2. Clear the canvas when not hovering over the image.

Let’s quickly add these two changes to our hook:

Change the useMouse hook to:

function useMouse(ref: React.RefObject<HTMLElement>) {
const [mouse, setMouse] = useState<{ x: number; y: number, isActive: boolean }>({ x: 0, y: 0, isActive: false });
useEffect(() => {
if (ref.current) {
const handleMouseMove = (e: MouseEvent) => {
// get mouse position relative to ref
const rect = ref.current?.getBoundingClientRect();
if (rect) {
setMouse({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
isActive: true,
});
}
};
const handleMouseOut = (e: MouseEvent) => {
setMouse({
x: 0,
y: 0,
isActive: false,
});
}
ref.current.addEventListener("mousemove", handleMouseMove);
ref.current.addEventListener("mouseout", handleMouseOut);
return () => {
ref.current!.removeEventListener("mousemove", handleMouseMove);
ref.current!.removeEventListener("mouseout", handleMouseOut);
};
}
});
return mouse;
}

We’ve added the isActive flag to let us know if the mouse is hovering over the image or not.

Change the useMouseOverZoom hook to:

export function useMouseOverZoom(
source: React.RefObject<HTMLImageElement>,
target: React.RefObject<HTMLCanvasElement>,
cursor: React.RefObject<HTMLElement>,
radius = 25
) {
// Capture Mouse position
const { x, y, isActive } = useMouse(source);
// Compute the part of the image to zoom based on mouse position
const zoomBounds = useMemo(() => {
return {
left: x - radius,
top: y - radius,
width: radius * 2,
height: radius * 2,
}
}, [x, y]);
// move the cursor to the mouse position
useEffect(() => {
if (cursor.current) {
const { left, top, width, height } = zoomBounds;
cursor.current.style.left = `${left}px`;
cursor.current.style.top = `${top}px`;
cursor.current.style.width = `${width}px`;
cursor.current.style.height = `${height}px`;
cursor.current.style.display = isActive ? "block" : "none";
}
}, [zoomBounds, isActive]);
// draw the zoomed image on the canvas
useEffect(() => {
if (source.current && target.current) {
const ctx = target.current.getContext("2d");
if (ctx) {
if (isActive) {
const { left, top, width, height } = zoomBounds;
const imageRatio = source.current.naturalWidth / source.current.width;
ctx.drawImage(
source.current,
left * imageRatio,
top * imageRatio,
width * imageRatio,
height * imageRatio,
0,
0,
target.current.width,
target.current.height
);
}
else {
// clear canvas
ctx.clearRect(0, 0, target.current.width, target.current.height);
}
}
}
}, [zoomBounds, isActive])
}

Here, We use the isActive flag from the useMouse hook to (1) show and hide the cursor element and (2) add a condition to our canvas rendering section, to draw the image if true, or clear the canvas otherwise.

You can access the complete code for the entire setup at: https://github.com/a2-coder/tutorial-mouseover-zoom

Conclusion

Here, we have our own react application with the “Mouse-Over Image Zoom Effect”.

Moreover, we have used a custom hook to define our entire workflow, hence it’s completely reusable and highly customizable 🥳. Feel free to use this on your react projects just by copying the hooks.ts file onto your project source.

I hope this tutorial was helpful to you. If yes, please give a 👏 to keep me motivated 🧡

--

--

Arjun Palakkazhi

Solutions Architect @ Sketchmonk. Full-Stack Developer. Data Scientist.