DEV Community

Cover image for Awesome animated cursor with React Hooks⚡️
Andriy Chemerynskiy
Andriy Chemerynskiy

Posted on • Updated on

Awesome animated cursor with React Hooks⚡️

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.

Cursor GIF

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.

Cursor GIF

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.

Cursor GIF

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.

Cursor GIF

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!

Discussion (14)

Collapse
richardnguyen99 profile image
Richard Nguyen

First of all, it's a great tutorial. But I have an issue working with internal navigation components, like @reach/router and Gatsby 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!

Collapse
andrewchmr profile image
Andriy Chemerynskiy Author

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? 🙂

Collapse
richardnguyen99 profile image
Richard Nguyen

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 and gatsby-link, the app doesn't reload so the state doesn't reload as well. My solution is to use the useLocation hook from @reach/router and put it in the useEffect's deps, like:

const location = useLocation();

useEffect(() => {
    addEventListeners();
    handleLinkHoverEvents();
    return () => removeEventListeners();
  }, [location]);

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!

Collapse
hurrellt profile image
Tomás Hurrell

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!

Collapse
andrewchmr profile image
Andriy Chemerynskiy Author

Thank you! I checked your website and you have to override geist-ui link and buttons styles by adding cursor: 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/themes

Collapse
dnirns profile image
dnirns

Hiya, 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?

Collapse
andrewchmr profile image
Andriy Chemerynskiy Author

Well, I have no idea why it happens :D

As a workaround, you can try adding mouseenter/mouseleave events to document.body instead of document to fix this issue

Try this:

 document.body.addEventListener("mouseenter", onMouseEnter);
 document.body.addEventListener("mouseleave", onMouseLeave);

Thank you for your comment!

Collapse
jets0m profile image
Richard Butler

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 :)

Collapse
andrewchmr profile image
Andriy Chemerynskiy Author

Thank you!

That's a good point! Fixed it ;)

Collapse
rhoadiemusic profile image
Fabián Ibarra

Thank you! amazing content!

Collapse
andrewchmr profile image
Andriy Chemerynskiy Author

Thanks, looking forward to publishing more cool stuff ;)

Collapse
ivanjeremic profile image
Ivan Jeremic

For me is the cursor unusable it lags the whole time, any idea why?

Collapse
andrewchmr profile image
Andriy Chemerynskiy Author

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

Collapse
ivanjeremic profile image
Ivan Jeremic

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.