profile picture of Olivier Larose

Olivier Larose

September 24, 2023

/

Intermediate

/

Medium

Sticky Cursor

Build a Sticky Cursor Effect with Next.js, Framer Motion and Trigonometry

A website animation tutorial featuring a sticky and magnetic cursor effect made with Next.js, Framer Motion and the Math.Atan2() function.

Live DemoSource codeVideo Tutorial
background video

Initializing the project

Let's start the project by creating a Next.js application. We can do that by running npx create-next-app@latest client inside of a terminal.

Adding the HTML and CSS

We can delete everything in the page.js , global.css and page.module.css and add our own HTML and CSS, to start with a nice blank application.

  • We will use Sass for the stylesheets, so we can run npm i sass.
  • We will use Framer Motion for the animation, so we can run npm i framer-motion.

Page Component

The parent component will act as the parent of the two main components.

page.js

page.mod...

1

'use client';

2

import styles from './page.module.scss'

3

import Header from '../components/header';

4

import StickyCursor from '../components/stickyCursor';

5

6

export default function Home() {

7

8

return (

9

<main className={styles.main}>

10

<Header/>

11

// <StickyCursor/>

12

</main>

13

)

14

}

Header Component

componen...

header/s...

1

import styles from './style.module.scss';

2

3

export default function Index(props, ref) {

4

return (

5

<div className={styles.header}>

6

<div className={styles.burger}>

7

</div>

8

</div>

9

)}

We should have something like this:

Screenshot of the HTML and CSS results

Moving the Cursor

Here I'll move the cursor on mouse move with a mix of event listeners and Framer Motion.

page.js

1

import StickyCursor from '../components/stickyCursor'

2

...

3

4

export default function Home() {

5

6

return (

7

<main className={styles.main}>

8

<Header/>

9

<StickyCursor/>

10

</main>

11

)

12

}

To move the cursor, I use two hooks from Framer Motion:

  • useMotionValue: a hook that returns an object with an internal state. Allows you to animate without re-rendering the whole components.
  • useSpring: a hook that returns a motion value with spring physics applied.

componen...

stickyCu...

1

'use client';

2

import { useEffect } from 'react';

3

import styles from './style.module.scss';

4

import { motion, useMotionValue, useSpring } from 'framer-motion';

5

6

export default function index({stickyElement}) {

7

8

const cursorSize = 15;

9

const mouse = {

10

x: useMotionValue(0),

11

y: useMotionValue(0)

12

}

13

14

const smoothOptions = {damping: 20, stiffness: 300, mass: 0.5}

15

const smoothMouse = {

16

x: useSpring(mouse.x, smoothOptions),

17

y: useSpring(mouse.y, smoothOptions)

18

}

19

20

const manageMouseMove = e => {

21

const { clientX, clientY } = e;

22

mouse.x.set(clientX - cursorSize / 2);

23

mouse.y.set(clientY - cursorSize / 2);

24

}

25

26

useEffect( () => {

27

window.addEventListener("mousemove", manageMouseMove);

28

return () => {

29

window.removeEventListener("mousemove", manageMouseMove)

30

}

31

}, [])

32

33

return (

34

<div className={styles.cursorContainer}>

35

<motion.div

36

style={{

37

left: smoothMouse.x,

38

top: smoothMouse.y,

39

}}

40

className={styles.cursor}>

41

</motion.div>

42

</div>

43

)

44

}

We should have something like this:

Sticking the cursor

The first step to stick the cursor is to find a way to pass a ref from a component to another. We can do this using the forwardRef from React.

Here I create a ref in the parent of both components.

page.js

1

...

2

export default function Home() {

3

4

const stickyElement = useRef(null);

5

6

return (

7

<main className={styles.main}>

8

<Header ref={stickyElement}/>

9

<StickyCursor stickyElement={stickyElement}/>

10

</main>

11

)

12

}

I use the forwardRef here to pass down the ref coming from the parent.

componen...

header/s...

1

import { forwardRef } from 'react';

2

import styles from './style.module.scss';

3

import Magnetic from '../magnetic';

4

5

const Header = forwardRef(function index(props, ref) {

6

return (

7

<div className={styles.header}>

8

<Magnetic>

9

<div className={styles.burger}>

10

<div ref={ref} className={styles.bounds}></div>

11

</div>

12

</Magnetic>

13

</div>

14

)}

15

)

16

17

export default Header
  • The bounds scale when we hover on it to have a larger range for the mouse leave event.

  • The Magnetic Component comes from an animation I made in the past.

Then I can use the ref coming from the header and stick the cursor to it.

components/stickyCursor/index.jsx

1

'use client';

2

import { useEffect, useState } from 'react';

3

import styles from './style.module.scss';

4

import { motion, useMotionValue, useSpring } from 'framer-motion';

5

6

export default function index({stickyElement}) {

7

8

const [isHovered, setIsHovered] = useState(false);

9

const cursorSize = isHovered ? 60 : 15;

10

11

const mouse = {

12

x: useMotionValue(0),

13

y: useMotionValue(0)

14

}

15

16

//Smooth out the mouse values

17

const smoothOptions = {damping: 20, stiffness: 300, mass: 0.5}

18

const smoothMouse = {

19

x: useSpring(mouse.x, smoothOptions),

20

y: useSpring(mouse.y, smoothOptions)

21

}

22

23

const manageMouseMove = e => {

24

const { clientX, clientY } = e;

25

const { left, top, height, width } = stickyElement.current.getBoundingClientRect();

26

27

//center position of the stickyElement

28

const center = {x: left + width / 2, y: top + height / 2}

29

30

if(isHovered){

31

32

//distance between the mouse pointer and the center of the custom cursor and

33

const distance = {x: clientX - center.x, y: clientY - center.y}

34

35

//move mouse to center of stickyElement + slightly move it towards the mouse pointer

36

mouse.x.set((center.x - cursorSize / 2) + (distance.x * 0.1));

37

mouse.y.set((center.y - cursorSize / 2) + (distance.y * 0.1));

38

}

39

else{

40

//move custom cursor to center of stickyElement

41

mouse.x.set(clientX - cursorSize / 2);

42

mouse.y.set(clientY - cursorSize / 2);

43

}

44

}

45

46

const manageMouseOver = e => {

47

setIsHovered(true)

48

}

49

50

const manageMouseLeave = e => {

51

setIsHovered(false)

52

}

53

54

useEffect( () => {

55

stickyElement.current.addEventListener("mouseenter", manageMouseOver)

56

stickyElement.current.addEventListener("mouseleave", manageMouseLeave)

57

window.addEventListener("mousemove", manageMouseMove);

58

return () => {

59

stickyElement.current.removeEventListener("mouseenter", manageMouseOver)

60

stickyElement.current.removeEventListener("mouseleave", manageMouseLeave)

61

window.removeEventListener("mousemove", manageMouseMove)

62

}

63

}, [isHovered])

64

65

return (

66

<div className={styles.cursorContainer}>

67

<motion.div

68

style={{

69

left: smoothMouse.x,

70

top: smoothMouse.y,

71

}}

72

animate={{

73

width: cursorSize,

74

height: cursorSize

75

}}

76

className={styles.cursor}>

77

</motion.div>

78

</div>

79

)

80

}

We should have something like this:

Stretching the cursor

components/stickyCursor.jsx

1

...

2

import { motion, useMotionValue, useSpring, transform, animate } from 'framer-motion';

3

4

export default function index({stickyElement}) {

5

6

const scale = {

7

x: useMotionValue(1),

8

y: useMotionValue(1)

9

}

10

11

const manageMouseMove = e => {

12

const { clientX, clientY } = e;

13

const { left, top, height, width } = stickyElement.current.getBoundingClientRect();

14

15

//center position of the stickyElement

16

const center = {x: left + width / 2, y: top + height / 2}

17

18

if(isHovered){

19

20

//distance between the mouse pointer and the center of the custom cursor and

21

const distance = {x: clientX - center.x, y: clientY - center.y}

22

23

//stretch based on the distance

24

const absDistance = Math.max(Math.abs(distance.x), Math.abs(distance.y));

25

const newScaleX = transform(absDistance, [0, height/2], [1, 1.3])

26

const newScaleY = transform(absDistance, [0, width/2], [1, 0.8])

27

scale.x.set(newScaleX);

28

scale.y.set(newScaleY);

29

...

30

}

31

...

32

}

33

34

const manageMouseLeave = e => {

35

...

36

animate(cursor.current, { scaleX: 1, scaleY: 1 }, {duration: 0.1}, { type: "spring" })

37

}

38

39

useEffect( () => {

40

stickyElement.current.addEventListener("mouseenter", manageMouseOver)

41

stickyElement.current.addEventListener("mouseleave", manageMouseLeave)

42

...

43

return () => {

44

stickyElement.current.removeEventListener("mouseenter", manageMouseOver)

45

stickyElement.current.removeEventListener("mouseleave", manageMouseLeave)

46

...

47

}

48

}, [isHovered])

49

50

return (

51

<div className={styles.cursorContainer}>

52

<motion.div

53

ref={cursor}

54

style={{

55

scaleX: scale.x,

56

scaleY: scale.y,

57

...

58

}}

59

...

60

</motion.div>

61

</div>

62

)

63

}

We should have something like this:

Rotating the cursor

To rotate the cursor, I use the Math.Atan2() function with the transformTemplate tag to change the order of the transform property.

We can calculate the necessary angle by using the distance inside the Math.Atan2() function.

Screenshot of the HTML and CSS results

components/stickyCursor.jsx

1

...

2

import { motion, useMotionValue, useSpring, transform, animate } from 'framer-motion';

3

4

export default function index({stickyElement}) {

5

6

...

7

const rotate = (distance) => {

8

const angle = Math.atan2(distance.y, distance.x);

9

animate(cursor.current, { rotate: `${angle}rad` }, {duration: 0})

10

}

11

12

const manageMouseMove = e => {

13

const { clientX, clientY } = e;

14

const { left, top, height, width } = stickyElement.current.getBoundingClientRect();

15

16

//center position of the stickyElement

17

const center = {x: left + width / 2, y: top + height / 2}

18

19

if(isHovered){

20

21

//distance between the mouse pointer and the center of the custom cursor and

22

const distance = {x: clientX - center.x, y: clientY - center.y}

23

24

//rotate

25

rotate(distance)

26

...

27

}

28

}

29

30

const template = ({rotate, scaleX, scaleY}) => {

31

return `rotate(${rotate}) scaleX(${scaleX}) scaleY(${scaleY})`

32

}

33

34

return (

35

<div className={styles.cursorContainer}>

36

<motion.div

37

transformTemplate={template}

38

...

39

</motion.div>

40

</div>

41

)

42

}

43

We should have something like this:

Wrapping up

That's it for this animation!

That was a lot of work for a simple animation but it ends being super satisfying, simple and yet quite impressive. Hope you learned a lot!

-Oli

Related Animations

image

June 2, 2024

Mouse Image Distortion

A website animation featuring an image distortion in a curved, using the sin function, React, React Three Fiber and Framer Motion

image

May 4, 2024

Paint Reveal

A website tutorial on making a paint reveal / erasing effect using the destination out blend mode of the canvas API, made with React and Next.js

image

March 8, 2024

Blend Mode Cursor

A website tutorial featuring a moving cursor on mouse move, colored with CSS blend mode difference, made with React and GSAP. Inspired by https://trionn.com/