Interactive Mouse-Tracking Eyes with React and Tailwind CSS 👀
Learn how to bring your web projects to life with a fun, step-by-step guide to mouse-tracking animations.
Introduction
Hey there!
When I was building my portfolio, I faced a small design dilemma with my navigation bar:
I wanted to remove the "Portfolio" heading from the top-left corner without shifting the navigation menus to that spot. I needed something creative to fill that empty space. While logos are a common choice, I didn’t have one ready at the time. That’s when an idea struck me: why not add mouse-tracking eyes ?
This not only solved my problem but also gave the navigation bar a unique, playful look that stood out from the usual designs. Here's the result:
In this article, I’ll walk you through how to create these mouse-tracking eyes step by step using React and Tailwind CSS.
Project Setup
Before we start coding, open a project folder. You can either create a new project or open an existing one. I'll open one of my existing Next.js projects to continue. Then, create a new component to write our code. I'll name it Eyeball.tsx
. Now we are ready to start coding our new component.
Component creation
Setting up mouse tracking
When building mouse-tracking eyes, the first thing we need to know is the position of the cursor, so the pupil can move towards it. For this task, we can use an event called mousemove.
The mousemove
event is fired at an element when a pointing device (usually a mouse) is moved while the cursor's hotspot is inside it.
import React, { useState, useEffect, useRef } from "react";
const EyeBall = () => {
const [mouseX, setMouseX] = useState<number>(0);
const [mouseY, setMouseY] = useState<number>(0);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMouseX(e.clientX);
setMouseY(e.clientY);
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
}
In the code above, I created two states, mouseX
and mouseY
, to store the cursor positions. The useEffect
hook adds an event listener to track mouse movement (mousemove
) and updates the state whenever the mouse moves.
If you'd like, you can check the cursor's position by adding a simple console.log()
in the useEffect()
.
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
console.log(`x-axis:- ${e.clientX} | y-axis:- ${e.clientY}`);
setMouseX(e.clientX);
setMouseY(e.clientY);
};
...
}, []);
In the end of the the useEffect
, I have added the cleanup function. When the component unmounts, the event listener is also removed.
What can happen without cleanup ?
Memory usage increases unnecessarily.
Wasted computations continue even when the component is gone.
Possible bugs from duplicated listeners.
Performance degradation over time.
If you're curious about when the component gets unmounted, you can add a simple console.log()
here as well.
useEffect(() => {
...
return () => {
window.removeEventListener("mousemove", handleMouseMove)
console.log("EyeBall component unmounted")
};
}, []);
By the end, you will be able to understand that this component unmounts by,
Navigating to another page (where this component isn’t used)
- React unmounts
<EyeBall />
, and the event listener is cleaned up.
- React unmounts
Refreshing the page
- The browser reloads the app, unmounting all components, including
<EyeBall />
. The event listener is cleaned up during unmount.
- The browser reloads the app, unmounting all components, including
Closing the browser/tab
- The browser kills the entire app process, so React unmounting isn't necessary. The OS handles cleanup.
Cleaning up is a simple but vital step to ensure that your application is efficient and bug-free.
Detecting the Center of the Eye
The eyeball needs to know its own center position (the middle of the circle) to calculate the angle and distance between the pupil and the mouse cursor.
const EyeBall = () => {
...
const eyeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
...
}, []);
const calculatePupilPosition = () => {
const eye = eyeRef.current.getBoundingClientRect();
const eyeCenterX = eye.left + eye.width / 2;
const eyeCenterY = eye.top + eye.height / 2;
};
}
In the above code, useRef<HTMLDivElement>(null)
initializes a reference to a DOM element with an initial value of null
. So, now the eyeRef
refers to the DOM element of the eyeball. This lets us measure its position on the screen.
useRef is a React Hook that provides a way to persist a mutable reference to a DOM element or a value across renders without causing a re-render.
const eye = eyeRef.current.getBoundingClientRect();
eyeRef.current
will hold the reference to the actual DOM element once it is assigned.
What is getBoundingClientRect()
?
getBoundingClientRect()
is JavaScript method available on DOM elements.It returns an object with information about the size and position of the element relative to the viewport.
Common Properties of the Returned Object
To see what it returns, we need to render a component with useRef
. Create a component like the one below, export the EyeBall
component, and then render it.
We will render and export this in the next sections. I'm showing it here to give you an idea of the data that comes back from getBoundingClientRect()
.
return (
<div
ref={eyeRef}
className="w-12 h-12 bg-white border-2 border-black rounded-full"
>
</div>
);
When the component is rendered, React will attach the actual DOM node (a <div>
element in this case) to eyeRef.current
.
const eye = eyeRef.current.getBoundingClientRect();
console.log(eye);
You'll see an object with the structure shown below. The data will change depending on the viewport, object position, and so on, but the structure will look the same.
{
"x": 360,
"y": 520.7999877929688,
"width": 48,
"height": 48,
"top": 520.7999877929688,
"right": 408,
"bottom": 568.7999877929688,
"left": 360
}
Right now, my component is set up like this. It's inside a centered <div />
, and the <EyeBall />
is also centered within it.
These are the properties of the returned object.
x
andy
: The top-left corner of the element relative to the viewport.width
andheight
: The width and height of the element.top
,right
,bottom
,left
: The distances from the respective edges of the viewport.
Source :- https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
Calculating the center
Now, using the getBoundingClientRect()
method, we can find the position of the element. Specifically, it returns:
left
: The x-coordinate of the left edge of the element relative to the viewport.top
: The y-coordinate of the top edge of the element relative to the viewport.
To find the center of the element, we have to calculate the horizontal and vertical center.
Horizontal Center (x-coordinate)
const eyeCenterX = eye.left + eye.width / 2;
Start from the left edge of the element (
eye.left
).Add half of the element's width (
eye.width / 2
) to get to the center horizontally.
Vertical Center (y-coordinate)
const eyeCenterY = eye.top + eye.height / 2;
Start from the top edge of the element (
eye.top
).Add half of the element's height (
eye.height / 2
) to get to the center vertically.
Let's Dive Deeper
Let’s assume there is an element (circle) in middle of a page and the bounding box of the element is,
const circle = {
left: 100, // x-coordinate of the left edge
top: 200, // y-coordinate of the top edge
width: 50, // element's width
height: 50 // element's height
};
I drew dashed bounding box enclosing the circle because getBoundingClientRect()
considers the dimensions of the box surrounding the circle (not the visual curve of the circle itself).
The calculations for the center will be,
// centerX = circle.left + circle.width / 2;
// centerY = circle.top + circle.height / 2;
circleCenterX = 100 + 50 / 2; // 100 + 25 = 125
circleCenterY = 200 + 50 / 2; // 200 + 25 = 225
So, the center of the element (circle) is at (125, 225)
relative to the viewport.
Calculating the Pupil's Position
After initializing the eye in the page, next task is to move the pupil towards the cursor. To achieve this, we need to calculate:
The distance between pupil and the eye.
The angle between the mouse and the center of the eye.
const EyeBall = () => {
...
const calculatePupilPosition = () => {
...
const deltaX = mouseX - eyeCenterX;
const deltaY = mouseY - eyeCenterY;
const distance = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2), 10);
const angle = Math.atan2(deltaY, deltaX);
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
return { x, y };
};
}
Distance between pupil and the eye
To explain this more clearly, let me use the following example.
To find the distance between those two, first we have to find the horizontal and vertical distances between the mouse and the center of eye.
Horizontal Distance (x-coordinate)
const deltaX = mouseX - eyeCenterX;
Takes the horizontal position of the cursor
mouseX
.Subtract the horizontal position of the center of the eye
eyeCenterX
frommouseX
to find the horizontal difference between the two positions.
Vertical Distance (y-coordinate)
const deltaY = mouseY - eyeCenterY;
Takes the vertical position of the cursor
mouseY
.Subtract the vertical position of the center of the eye
eyeCenterY
frommouseY
to find the vertical difference between the two positions.
Now we have determined the vertical and horizontal distances between the two points. However, we still need to calculate the direct distance between the center of the eye and the cursor. This is the distance I am referring to:
To calculate the distance show by the green line, we can simply use the Pythagorean theorem.
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
But that's not all, We don't want the pupil to follow the cursor all the way. We need to limit how far the pupil can move. We can do this by using the Math.min()
method.
const distance = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2), 10);
You can modify the value of 10 as needed. In this case, if the distance between the cursor and the center of the eye is less than 10, the result from the Pythagorean calculation will be used. If it exceeds 10, the distance will be capped at 10. Thus, the pupil cannot move more than 10px
from the center.
Angle between the mouse and the center of the eye
The next task is to find the angle between the mouse and the center of the eye and calculate a new x
and y
offset at a specific distance in the direction of the cursor.
const angle = Math.atan2(deltaY, deltaX);
Math.atan2()
is a method which measures the counterclockwise angle θ
, in radians, between the positive x-axis and the point (x, y)
.
This is actually similar to a graph. Consider the angle θ
as the gradient (m), and it is equal to tan(θ)
. We can find the value of θ
by taking the inverse of tan(θ)
. Here Math.atan2()
calculates the angle in radians between the center of the circle and the cursor.
It considers both the deltaX
and deltaY
values, so it correctly determines the angle relative to the positive X-axis. Here deltaX = 8
and deltaY = 6
. So, the resulting angle θ = arctan2(6,8)
.
Next we can compute the new x
and y
by resolving this into 2 components (vector resolution).
Vector resolution is the process of broking down a single vector into two or more smaller vectors. There are many resources available to learn about vector resolution if you're not familiar with it. It only requires some basic understanding of trigonometry. Below, I have provided a quick overview of that concept.
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
Math.cos(angle) * distance
resolves the vector onto the X-axis.Math.sin(angle) * distance
resolves the vector onto the Y-axis.
this x
and y
are the offset values relative to the center of the eye.
Let’s look at the example for a bit,
distance = 10
Angle from
Math.atan2(6, 8) ≈ 0.6435
radians.x = cos(0.6435) * 10 = 8
y = sin(0.6435) * 10 = 6
In this example, we directly used the distance as 10
, but within the program, we limited the distance to prevent it from exceeding the circle's boundary. Since my drawings are not to scale, the results may appear confusing. Initially, I assumed the circle's width to be 50px
. Therefore, the x, y
difference has to be exceed 25px
to move beyond the circle. These values are merely illustrative; the key takeaway is to grasp the underlying concept.
Why Find the Angle If Coordinates Are Known ?
Occasionally, you might encounter the question why find the Angle if coordinates are known ?
There are few reasons,
To Move an Object in a Direction (Not Just Jump to the Cursor)
Knowing the cursor position (or target coordinates) tells you where to go, but it doesn’t inherently describe how to move toward that position.
Angle tells you the direction relative to the horizontal axis.
This is especially helpful if you want to move an object smoothly toward the target (e.g., in small steps or animations).
Consistency in Movement
If you only use raw coordinate differences (
deltaX
anddeltaY
) directly, you could run into situations where the movement isn’t proportional.Using trigonometry (
Math.atan2
,Math.cos
, andMath.sin
) ensures the direction is normalized and consistent, no matter the magnitude of thedeltaX
anddeltaY
.
Vector Magnitude and Direction
Coordinates give you absolute positions, but angles are essential to describe relative motion.
By finding the angle, you can resolve movement into independent components (X and Y), which is key in:
Smooth animations
Physics-based movements
Calculating positions for circular or angular paths
Handling No Cursor Movement
const calculatePupilPosition = () => {
if (!eyeRef.current) return { x: 0, y: 0 };
...
return { x, y };
};
When the page first loads, to keep the pupil at the center, we need to write a conditional statement to set the pupil at { x: 0, y: 0 }
, which is the center.
Rendering the Eyeball
The eyeball consists of,
A white circle (the eyeball itself).
A black circle (the pupil) whose position is dynamically updated based on the mouse movement.
...
return (
<div
ref={eyeRef}
className="w-12 h-12 bg-white border-2 border-black rounded-full flex items-center justify-center"
>
<div
className="w-4 h-4 bg-black rounded-full"
style={{
transform: `translate(${pupilPosition.x}px, ${pupilPosition.y}px)`,
}}
/>
</div>
);
eyeRef
is attached to the eyeball container to measure its position. The pupil (div
inside the eye) is styled to move using transform: translate(...)
based on the calculated x
and y
offsets.
I have applied some basic styling, but feel free to customize it as you prefer.
Final Code
import React, { useState, useEffect, useRef } from "react";
const EyeBall = () => {
const [mouseX, setMouseX] = useState<number>(0);
const [mouseY, setMouseY] = useState<number>(0);
const eyeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMouseX(e.clientX);
setMouseY(e.clientY);
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove)
};
}, []);
const calculatePupilPosition = () => {
if (!eyeRef.current) return { x: 0, y: 0 };
const eye = eyeRef.current.getBoundingClientRect();
const eyeCenterX = eye.left + eye.width / 2;
const eyeCenterY = eye.top + eye.height / 2;
const deltaX = mouseX - eyeCenterX;
const deltaY = mouseY - eyeCenterY;
const distance = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2), 10);
const angle = Math.atan2(deltaY, deltaX);
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
return { x, y };
};
const pupilPosition = calculatePupilPosition();
return (
<div
ref={eyeRef}
className="w-12 h-12 bg-white border-2 border-black rounded-full flex items-center justify-center"
>
<div
className="w-4 h-4 bg-black rounded-full"
style={{
transform: `translate(${pupilPosition.x}px, ${pupilPosition.y}px)`,
}}
/>
</div>
);
};
export default EyeBall;
Now, go ahead and import this component into the page where you want to use it and render it. Just remember to make it a client component
since we're using useState
, useEffect
, and useRef
.
"use client"
import React from 'react'
import EyeBall from '@/components/EyeBall'
const Eyes = () => {
return (
<div className='flex items-center justify-center h-[100%]'>
<div className='flex space-x-4 p-40 bg-white'>
<EyeBall />
<EyeBall />
</div>
</div>
)
}
export default Eyes
Thank you
Thank you for taking the time to read this article all the way through to the end. I truly appreciate your interest and hope you found the information provided to be both insightful and useful. If you have any feedback or if there are specific topics you would like me to explore in future articles, please feel free to share your thoughts in the comments section below.