Don't you find built-in cursors kinda boring?🥱 Me too. So I built my own.
Let's start by adding basic styles and logic to our cursor.
.cursor {
width: 40px;
height: 40px;
border: 2px solid #fefefe;
border-radius: 100%;
position: fixed;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 9999;
mix-blend-mode: difference;
}
html, body {
cursor: none;
background-color: #121212;
}
const Cursor = () => {
return <div className="cursor"/>
}
ReactDOM.render(
<div className="App">
<Cursor/>
</div>,
document.getElementById('root')
);
Now we want to change our cursor's position based on mouse moves.
const Cursor = () => {
+ const [position, setPosition] = useState({x: 0, y: 0});
+
+ useEffect(() => {
+ addEventListeners();
+ return () => removeEventListeners();
+ }, []);
+
+ const addEventListeners = () => {
+ document.addEventListener("mousemove", onMouseMove);
+ };
+
+ const removeEventListeners = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ };
+
+ const onMouseMove = (e) => {
+ setPosition({x: e.clientX, y: e.clientY});
+ };
+
- return <div className="cursor"/>
+ return <div className="cursor"
+ style={{
+ left: `${position.x}px`,
+ top: `${position.y}px`
+ }}/>
}
...
When a component is mounted we add an event listener that handles mousemove
event and remove it when the component is going to unmount. In onMouseMove
function we set new cursor's position based on e.clientX
and e.clientY
properties.
Now our cursor reacts to mouse moves, but as you can see it doesn't hide when the mouse leaves the screen. So let's fix it!
.cursor {
...
+ transition: all 150ms ease;
+ transition-property: opacity;
}
+ .cursor--hidden {
+ opacity: 0;
+ }
...
+ import classNames from "classnames";
const Cursor = () => {
const [position, setPosition] = useState({x: 0, y: 0});
+ const [hidden, setHidden] = useState(false);
...
const addEventListeners = () => {
document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseenter", onMouseEnter);
+ document.addEventListener("mouseleave", onMouseLeave);
};
const removeEventListeners = () => {
document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseenter", onMouseEnter);
+ document.removeEventListener("mouseleave", onMouseLeave);
};
+
+ const onMouseLeave = () => {
+ setHidden(true);
+ };
+
+ const onMouseEnter = () => {
+ setHidden(false);
+ };
...
+
+ const cursorClasses = classNames(
+ 'cursor',
+ {
+ 'cursor--hidden': hidden
+ }
+ );
+
- return <div className="cursor"
+ return <div className={cursorClasses}
style={{
left: `${position.x}px`,
top: `${position.y}px`
}}/>
}
...
So, I add mouseleave
and mouseenter
handler. When the mouse enters the screen's opacity
becomes 1
and when leaves - equals to 0
. Additionally, I add classnames
library which is a simple utility for conditionally joining classNames together.
Now it looks way better, but let's add some more stuff!
Let's add click animation.
.cursor {
...
- transition-property: opacity;
+ transition-property: opacity, background-color, transform, mix-blend-mode;
...
}
+ .cursor--clicked {
+ transform: translate(-50%, -50%) scale(0.9);
+ background-color: #fefefe;
+ }
...
const Cursor = () => {
...
+ const [clicked, setClicked] = useState(false);
const addEventListeners = () => {
...
+ document.addEventListener("mousedown", onMouseDown);
+ document.addEventListener("mouseup", onMouseUp);
};
const removeEventListeners = () => {
...
+ document.removeEventListener("mousedown", onMouseDown);
+ document.removeEventListener("mouseup", onMouseUp);
};
+
+ const onMouseDown = () => {
+ setClicked(true);
+ };
+
+ const onMouseUp = () => {
+ setClicked(false);
+ };
...
const cursorClasses = classNames(
'cursor',
{
+ 'cursor--clicked': clicked,
'cursor--hidden': hidden
}
);
...
Mouse clicks are handled by mousedown
and mouseup
event. When the mouse is clicked, the cursor's scale changes to 0.9
and background to #fefefe
.
Let's move on to our final animation!
Now we will add some effects when links have hovered.
...
+ .cursor--link-hovered {
+ transform: translate(-50%, -50%) scale(1.25);
+ background-color: #fefefe;
+ }
+
+ a {
+ text-decoration: underline;
+ color: #fefefe;
+ }
...
const Cursor = () => {
...
+ const [linkHovered, setLinkHovered] = useState(false);
useEffect(() => {
addEventListeners();
+ handleLinkHoverEvents();
return () => removeEventListeners();
}, []);
+
...
+
+ const handleLinkHoverEvents = () => {
+ document.querySelectorAll("a").forEach(el => {
+ el.addEventListener("mouseover", () => setLinkHovered(true));
+ el.addEventListener("mouseout", () => setLinkHovered(false));
+ });
+ };
const cursorClasses = classNames(
'cursor',
{
'cursor--clicked': clicked,
'cursor--hidden': hidden,
+ 'cursor--link-hovered': linkHovered
}
);
...
}
ReactDOM.render(
<div className="App">
+ <a>This is a link</a>
<Cursor/>
</div>,
document.getElementById('root')
);
When a component is mounted, handleLinkHoverEvents
add event listeners to all link elements. When a link hovers, cursor--link-hovered
class is added.
In the final step, we will not render <Cursor/>
on mobile/touch devices.
+ const isMobile = () => {
+ const ua = navigator.userAgent;
+ return /Android|Mobi/i.test(ua);
+ };
const Cursor = () => {
+ if (typeof navigator !== 'undefined' && isMobile()) return null;
...
}
...
And we are done! Here is a full codepen example:
Adding custom cursor animation is not as difficult as it seems to be. I hope that this article will give you a basic idea of what you can do to customize your own cursor.
Thanks for reading!
Top comments (14)
First of all, it's a great tutorial. But I have an issue working with internal navigation components, like
@reach/router
andGatsby Link
. When I hover on those links, the hovering animation is triggered. But I click on them, the hovered prop doesn't change; it remains the same throughout the app (it's supposed to change to normal). It works perfectly for original anchor elements<a></a>
. Have any ideas to fix this? Thank you for your great post!Hmm, I was unable to reproduce it for
@reach/router
Here is the sandbox: codesandbox.io/s/reach-router-curs...
Can you provide more details? 🙂
I figured out the way how to fix it. So, when I use anchor elements for navigating, the app reloads so the states reloads too. But with internal links like
@reach/router
andgatsby-link
, the app doesn't reload so the state doesn't reload as well. My solution is to use theuseLocation
hook from@reach/router
and put it in theuseEffect
's deps, like:It will update the state whenever the route is changed.
I still have no idea why your sandbox is still working. But thank you for your response. Keep up with great contents like this one!
Hey! Awesome post!
I've been wanting to learn how to do this for quite some time!
I tried this while trying a component lib called geist-ui, and I'm having trouble hiding the pointer cursor for some buttons.
Here's my code, if someone has a workarround.
github.com/HurrellT/hurrellt.portf...
Thanks a lot!
Thank you! I checked your website and you have to override
geist-ui
link and buttons styles by addingcursor: none;
I didn't use
geist-ui
before but here is a guide on how you can do it: react.geist-ui.dev/en-us/guide/themesHiya, thanks for the great tutorial. I'm having an issue with the cursor and mouseenter/mouseleave not working properly in firefox (perfect in chrome however), was wondering if you have any insight into what might be going on?
Well, I have no idea why it happens :D
As a workaround, you can try adding
mouseenter/mouseleave
events todocument.body
instead ofdocument
to fix this issueTry this:
Thank you for your comment!
This is some great stuff! I had an issue when scrolling, the cursor scrolled with the page so I used fixed instead of absolute for the cursor and clientX & clientY instead of pageX & pageY when setting the position and it seemed to sort this out. Cheers for the great content :)
Thank you!
That's a good point! Fixed it ;)
Thank you! amazing content!
Thanks, looking forward to publishing more cool stuff ;)
For me is the cursor unusable it lags the whole time, any idea why?
Can you share some code snippet, please?
Maybe it happens due to unwanted rendering loops that force heavy calculations, but I cannot say more without more details
Yeah it feels like heavy calculations are happening but I can't find the bug, I also have the code not anymore but maybe I can find time to reproduce it.