Custom cursors have been a consistent trend in new websites. They are a great way to add micro-interactivity to your website. In this guide, we'll take a walk through setting up a custom cursor component in Vue/Nuxt using GSAP.
Creating the cursor
The first thing we'll want to do is create a new component that will house our markup for the cursor. I call mine `pointer` as in some javascript libraries/frameworks the word "cursor" can be reserved.
I will add this component into the default layout of my Nuxt project due to the fact that I want it on every page, and when our app changes routes, I want the cursor to remain on the screen.
<div class="pointer" aria-hidden="true" ref="pointer">
<span class="pointer-svg" ref="pointerSVG">
<span class="pointer-circle" ref="pointerCircle"></span>
</span>
</div>
Styling the cursor
// Hide the cursor by default.
// We'll enable it for devices that support it's interactions.
.pointer {
display: none;
}
@media (any-pointer: fine) {
.pointer {
display: block;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
user-select: none;
z-index: 200;
&-svg {
position: absolute;
display: block;
top: 0;
left: 0;
width: 48px;
height: 48px;
pointer-events: none;
opacity: 0;
will-change: transform;
}
&-circle {
position: relative;
display: block;
width: 48px;
height: 48px;
background: c('cursor');
border-radius: 50%;
clip-path: circle(50% at 50% 50%);
transform-origin: 50% 50%;
transform: scale(0);
transition: transform 0.3s ease-in-out, opacity 0.1s;
}
}
}
Adding some default interactivity
We're also going to add some styling for some default interactivity, that you can extend later on if you'd like to.
.pointer {
&.-visible {
.pointer-circle {
transform: scale(0.2);
}
&.-active {
.pointer-circle {
transform: scale(0.23);
}
}
}
}
Here we are styling for when the cursor gets the `.-visibile` class. This will be applied when the user has their mouse within the bounds of the browser window.
Creating our first mixin
Initializing our data
We'll create some variables to store our data, including the cursor size and position, the bounds (width and height) of elements within the Pointer, as well as some linear interpolated (lerp) values we'll calculate in the render function. We use linear interpolation because it will create a butter smooth transition for the cursor.
The mounted lifecycle
On mount of the Pointer component, we'll want to initialize our refs, assign the bounds of the cursor, and store the mouse position. You'll notice that for performance reasons, we don't do any calculations in the `mousemove` event listener. Instead, we're going to use `requestAnimationFrame` api to recursively loop through a render function, where we will do our calculations and animations. This is a more performant way to perform calculations, and will help slower devices from experiencing input lag (they may still experience visual lag).
The recursive render function
In the render function, we'll want all of our calculations to take place.
import { gsap } from 'gsap'
const lerp = (a, b, n) => (1 - n) * a + n * b
export const Pointer = {
name: 'Pointer',
data() {
return {
// Define the cursor parameters & sizes
cursor: {
x: 0,
y: 0,
width: 48,
height: 48,
},
// Define the cursor & circle props
props: {
bounds: {
circle: {
width: 0,
height: 0,
},
},
circle: {
x: {
previous: 0,
current: 0,
smooth: 0.3,
},
y: {
previous: 0,
current: 0,
smooth: 0.3,
},
},
},
}
},
methods: {
render(pointer) {
// Assign the current values of the cursor, subtracting half the circle width
this.props.circle.x.current = this.cursor.x - this.props.bounds.circle.width / 2
this.props.circle.y.current = this.cursor.y - this.props.bounds.circle.height / 2
// We will be using linear interpolation (lerp)
// to calculate positions for smooth transitions
for (const key in this.props.circle) {
this.props.circle[key].previous = lerp(
this.props.circle[key].previous,
this.props.circle[key].current,
this.props.circle[key].smooth
)
}
// Send the previously lerped positions to gsap
gsap.to(pointer.cursor, {
x: this.props.circle.x.previous,
y: this.props.circle.y.previous,
force3D: true,
overwrite: true,
duration: 0.2,
opacity: 1
})
// Callback to keep the render method recursive
requestAnimationFrame(() => {
this.render(pointer)
})
},
},
mounted() {
// Assign each element of the cursor via refs
const cursor = this.$refs.pointerSVG
const circle = this.$refs.pointerCircle
const cursorBounds = cursor.getBoundingClientRect()
// Assign the dimensions of the cursor, to be used in calculations later
this.props.bounds.circle.width = cursorBounds.width
this.props.bounds.circle.height = cursorBounds.height
// Track and store the cursor position
window.addEventListener('mousemove', event => {
this.cursor = { x: event.clientX, y: event.clientY }
})
// Create a recursive function that loops over the render method
requestAnimationFrame(() => {
this.render({ cursor, circle })
})
}
}