How to Build a Custom Cursor Effect in Next.js with GSAP

Introduction

Custom cursors are one of those small details that add "life" or personality to a website. Some time ago, I was browsing through some Awwwards websites and noticed the modified circular cursor that follows the mouse with a slight delay.

The effect is simple on the surface: a small circle follows the mouse with a slight delay, scales up when hovering over interactive elements, and disappears on mobile. But the implementation matters a lot. Do it wrong and you get jank. Do it right and the interaction feels effortless.

This guide covers how to build exactly that using Next.js (Pages Router), GSAP, and styled-components. No React state for mouse position, no requestAnimationFrame boilerplate. Just gsap.quickTo(), which handles the interpolation in a single line.

Here's what the finished effect looks like:

  • Small circular cursor that replaces the default
  • Smooth delayed movement (lerp-style lag)
  • Ring scales on hover over links, buttons, or any custom element
  • Hidden on mobile automatically

Why GSAP and Not CSS Transitions

The naive approach is to set the cursor's left and top with setState and use a CSS transition to smooth it out. That works until it doesn't: React re-renders on every mousemove, the transition easing fights with the animation frame timing, and the result looks slightly off on fast movements.

gsap.quickTo() solves all of this. It creates a cached setter that GSAP drives directly on the DOM element, outside of React's render cycle. The motion is frame-perfect and the easing is applied correctly at any speed.

const xTo = gsap.quickTo(element, 'x', { duration: 0.45, ease: 'power3' });
// later:
xTo(e.clientX); // no re-render, no jank

Building the Component

Install GSAP if you haven't already:

npm install gsap

Create components/CustomCursor/index.tsx:

import { useEffect, useRef } from 'react';
import styled from 'styled-components';
import gsap from 'gsap';

const CursorRing = styled.div`
    position: fixed;
    top: 0;
    left: 0;
    width: 20px;
    height: 20px;
    border: 1px solid rgba(255, 255, 255, 0.35);
    border-radius: 50%;
    pointer-events: none;
    z-index: 9999;
    transform: translate(-50%, -50%);
    backdrop-filter: blur(2px);
    background: rgba(255, 255, 255, 0.04);
    mix-blend-mode: difference;
    will-change: transform;

    @media (max-width: 768px) {
        display: none;
    }
`;

const CursorDot = styled.div`
    position: fixed;
    top: 0;
    left: 0;
    width: 4px;
    height: 4px;
    background: rgba(255, 255, 255, 0.9);
    border-radius: 50%;
    pointer-events: none;
    z-index: 10000;
    transform: translate(-50%, -50%);
    will-change: transform;

    @media (max-width: 768px) {
        display: none;
    }
`;

export default function CustomCursor() {
    const ringRef = useRef<HTMLDivElement>(null);
    const dotRef  = useRef<HTMLDivElement>(null);

    useEffect(() => {
        const ring = ringRef.current;
        const dot  = dotRef.current;
        if (!ring || !dot) return;

        // Ring follows with a slight lag, dot snaps almost instantly
        const xRing = gsap.quickTo(ring, 'x', { duration: 0.45, ease: 'power3' });
        const yRing = gsap.quickTo(ring, 'y', { duration: 0.45, ease: 'power3' });
        const xDot  = gsap.quickTo(dot,  'x', { duration: 0.08, ease: 'power3' });
        const yDot  = gsap.quickTo(dot,  'y', { duration: 0.08, ease: 'power3' });

        const moveCursor = (e: MouseEvent) => {
            xRing(e.clientX); yRing(e.clientY);
            xDot(e.clientX);  yDot(e.clientY);
        };

        const hoverEls = document.querySelectorAll('a, button, [data-cursor-hover]');

        const onEnter = () =>
            gsap.to(ring, { scale: 2.5, duration: 0.3, ease: 'power3.out' });
        const onLeave = () =>
            gsap.to(ring, { scale: 1,   duration: 0.3, ease: 'power3.out' });

        window.addEventListener('mousemove', moveCursor);
        hoverEls.forEach((el) => {
            el.addEventListener('mouseenter', onEnter);
            el.addEventListener('mouseleave', onLeave);
        });

        return () => {
            window.removeEventListener('mousemove', moveCursor);
            hoverEls.forEach((el) => {
                el.removeEventListener('mouseenter', onEnter);
                el.removeEventListener('mouseleave', onLeave);
            });
        };
    }, []);

    return (
        <>
            <CursorRing ref={ringRef} />
            <CursorDot  ref={dotRef}  />
        </>
    );
}

The Dual Cursor Pattern

The component renders two elements: a fast dot and a slow ring. The dot has duration: 0.08, which makes it feel almost physically attached to the pointer. The ring has duration: 0.45, which gives it that lagging elastic feel you see on Awwwards sites.

Running two separate quickTo instances at different speeds is the entire trick. No complex physics, no velocity tracking, just two different durations on the same coordinates. The visual gap between them creates the depth.

Hiding the Native Cursor

You need to suppress the browser's default cursor inside the section where your custom cursor is active. The cleanest way to do this in a Pages Router project is to scope it to a container rather than applying cursor: none globally.

Pass a scope selector to the component, and it will hide the native cursor only inside that element:

type Props = { scope?: string };

export default function CustomCursor({ scope }: Props) {
    useEffect(() => {
        const target = scope
            ? document.querySelector<HTMLElement>(scope)
            : document.body;

        if (target) target.style.cursor = 'none';

        // ... rest of setup

        return () => {
            if (target) target.style.cursor = '';
        };
    }, [scope]);
}

If you want the cursor site-wide, add it to _app.tsx without a scope. If you want it only on the hero, add an id to the section and pass it:

// pages/_app.tsx
<CustomCursor />

Or scoped:

// pages/index.tsx
<HeroSection id="hero">
    <CustomCursor scope="#hero" />
    ...
</HeroSection>

Adding Hover Targets

By default, the ring scales on a and button elements. To trigger the effect on anything else, add the data-cursor-hover attribute:

<div data-cursor-hover>
    Featured Project
</div>

That's all. No class names to remember, no additional wiring. The useEffect picks up everything matching a, button, [data-cursor-hover] at mount time.

const hoverEls = document.querySelectorAll('a, button, [data-cursor-hover]');

One limitation: hover listeners are attached at mount. If your page renders new interactive elements after mount (e.g., from a data fetch), you'll need to re-run the effect or attach listeners to those elements separately.

Scoping to the Hero Only

If you want the cursor effect on the hero but not the rest of the page, the scope prop handles it cleanly. Give your hero section an ID and pass it as the selector:

<section id="hero">
    <CustomCursor scope="#hero" />
    {/* hero content */}
</section>

The scope prop also tells the component where to query for hover targets, so the querySelectorAll is also scoped to that container.

Optional: Cursor Text on Hover

You can extend the cursor into a pill shape that reveals text like VIEW or OPEN on hover over project cards. This works well on dark portfolios with large image cards.

Adjust the styled component:

const CursorRing = styled.div`
    /* existing styles */
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 9px;
    font-weight: 600;
    letter-spacing: 0.1em;
    color: white;
    overflow: hidden;
`;

Then in the mouseenter handler:

const onEnter = (e: Event) => {
    const label = (e.currentTarget as HTMLElement).dataset.cursorLabel;
    if (ring.firstChild) ring.firstChild.textContent = label ?? '';
    gsap.to(ring, {
        scale: 3.5,
        borderRadius: '20px',
        duration: 0.3,
        ease: 'power3.out',
    });
};

Add the label via a data attribute:

<div data-cursor-hover data-cursor-label="VIEW">
    Project Card
</div>

Performance Tips

The most common mistake is driving cursor position with useState. This triggers a React re-render on every mousemove event, which is 60+ times a second. On a complex page that causes noticeable frame drops.

The right approach:

  • Store the cursor element in a ref, not state
  • Use gsap.quickTo() to animate it directly on the DOM node
  • Never call setState in a mousemove handler
// Don't do this
const [pos, setPos] = useState({ x: 0, y: 0 });
window.addEventListener('mousemove', (e) => setPos({ x: e.clientX, y: e.clientY }));

// Do this
const xTo = gsap.quickTo(ref.current, 'x', { duration: 0.4 });
window.addEventListener('mousemove', (e) => xTo(e.clientX));

The GSAP approach never touches React's reconciler. The animation runs entirely in GSAP's internal loop, and the DOM update happens once per frame via requestAnimationFrame internally.

If your site has a dark background and clean typography, keep the cursor subtle:

  • Thin 1px outlined ring, no fill
  • backdrop-filter: blur(2px) for a frosted glass feel
  • mix-blend-mode: difference so it inverts the color under it automatically
  • Scale to 2.5x on hover, not larger
  • No particles, trails, or color changes

The goal is to add personality without distraction. The cursor should feel like part of the interface, not a feature competing for attention.

Conclusion

A custom cursor done well is invisible until you notice it, and then you can't imagine the site without it. The whole thing comes down to two ideas: use gsap.quickTo() instead of React state, and separate the ring from the dot at different speeds. Everything else is styling.

The full component is less than 80 lines and has zero dependencies beyond GSAP, which you likely already have if you're doing any animation work in Next.js.

Resources

  1. GSAP quickTo docs
  2. GSAP with React
  3. styled-components docs
  4. mix-blend-mode on MDN