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;
}
Enter fullscreen mode Exit fullscreen mode
const Cursor = () => {
    return <div className="cursor"/>
}

ReactDOM.render(
    <div className="App">
        <Cursor/>
    </div>,
    document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

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`
+           }}/>
}

...
Enter fullscreen mode Exit fullscreen mode

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;
+ }

...
Enter fullscreen mode Exit fullscreen mode
+ 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`
            }}/>
}

...
Enter fullscreen mode Exit fullscreen mode

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;
+ }

...
Enter fullscreen mode Exit fullscreen mode
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
        }
    );

...
Enter fullscreen mode Exit fullscreen mode

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;
+ }

...
Enter fullscreen mode Exit fullscreen mode
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')
);

Enter fullscreen mode Exit fullscreen mode

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;
    ...
}

...
Enter fullscreen mode Exit fullscreen mode

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)

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

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

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

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

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

Thanks, looking forward to publishing more cool stuff ;)

Collapse
 
ivan_jrmc profile image
Ivan Jeremic

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

Collapse
 
andrewchmr profile image
Andriy Chemerynskiy

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
 
ivan_jrmc 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.