Insights Guide

C
u
s
t
o
m
c
u
r
s
o
r
m
i
c
r
o
-
i
n
t
e
r
a
c
t
i
v
i
t
y
u
s
i
n
g
G
S
A
P

Using
GSAP
and
a
linear
interpolation
function,
we'll
create
a
cursor
with
javascript
that
follows
your
mouse
around
the
screen.

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>
html
Pointer.vue

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;
        }
    }
}
scss
Pointer.vue

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);
            }
        }
    }
}
scss
Pointer.vue

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 })
        })
    }
}
javascript
mixins/Pointer.js