Next.js Custom Cursor: Build a Smooth Dot and Ring Follower

Published: October 8, 2026 · 9 min read

Build a custom cursor in Next.js: a dot and a trailing ring with requestAnimationFrame or Motion springs, growing on links, and switching off on touch devices and for reduced motion, with code.

Build a custom cursor that follows the mouse in Next.js

You are building a portfolio in Next.js and want that effect other creative sites have, where a ring trails the mouse around. But you are not sure where to start.

Here is the answer: lay a small element over the whole screen and move it to the mouse position.

Only the browser knows where the mouse is, so the cursor file gets "use client", the marker for browser-side code.

Move it by updating transform (the CSS property for moving and scaling elements), not position values. That keeps the motion smooth.

This guide covers:

  • Which sites a custom cursor suits, and which it does not

  • Two ways to build a dot and a trailing ring

  • Growing the ring over links and buttons

  • Switching the custom cursor off on touch devices and for reduced motion

  • CSS that keeps the native cursor in text fields

When a custom cursor helps, and when it gets in the way

A custom cursor works on sites where the visual impression matters most. For example:

  • Portfolios for designers and photographers

  • Brand and campaign pages

  • Galleries that show work at large sizes

There are two reasons. First, simply moving the mouse tells visitors the site was crafted with care.

Second, it highlights what is clickable. When the ring grows over a link, people know they can click it.

It is a poor fit for pages where people get work done, like admin panels or checkout. An unfamiliar cursor gets in the way when someone needs to click precisely.

Phones and tablets also have no mouse cursor at all. On touch devices, do not render the custom cursor.

Step 1: Build a custom cursor with a dot and a trailing ring

The small dot sticks right to the mouse, while the larger ring follows with a slight delay. That delay is what makes it feel smooth.

The ring moves a little closer on every repaint using requestAnimationFrame, the browser API that runs your code right before the next frame is drawn.

components/CustomCursor.tsx
"use client";

import { useEffect, useRef } from "react";

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

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

    let mouseX = -100;
    let mouseY = -100;
    let ringX = -100;
    let ringY = -100;
    let frame = 0;

    const onMove = (e: PointerEvent) => {
      mouseX = e.clientX;
      mouseY = e.clientY;
      dot.style.transform = "translate3d(" + mouseX + "px, " + mouseY + "px, 0)";
    };

    const tick = () => {
      ringX += (mouseX - ringX) * 0.2;
      ringY += (mouseY - ringY) * 0.2;
      ring.style.transform = "translate3d(" + ringX + "px, " + ringY + "px, 0)";
      frame = requestAnimationFrame(tick);
    };

    window.addEventListener("pointermove", onMove, { passive: true });
    frame = requestAnimationFrame(tick);

    return () => {
      window.removeEventListener("pointermove", onMove);
      cancelAnimationFrame(frame);
    };
  }, []);

  return (
    <div aria-hidden="true" className="pointer-events-none fixed inset-0 z-[100]">
      <div ref={ringRef} className="absolute left-0 top-0">
        <div className="h-9 w-9 -translate-x-1/2 -translate-y-1/2 rounded-full border border-zinc-900/50" />
      </div>
      <div ref={dotRef} className="absolute left-0 top-0">
        <div className="h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-900" />
      </div>
    </div>
  );
}

Key points in the code

  • pointer-events-none on the outer div keeps the cursor from stealing clicks

  • Position is driven by transform, not left or top, so the browser does not recalculate the layout

  • Each frame the ring closes 20% of the remaining distance; a smaller value than 0.2 means more lag

  • The inner div uses -translate-x-1/2 to center the circle on the pointer tip

  • Until the mouse moves, everything sits off-screen at -100px

The cursor is pure decoration, so it carries aria-hidden to keep it out of screen readers.

Writing the custom cursor with Motion springs

If you already use Motion (formerly Framer Motion), useSpring gives you the ring's lag. A spring chases its target and settles, like a real spring.

Lower stiffness means more lag; lower damping makes it overshoot and wobble back. Motion values update transform without re-rendering React, so this version stays light too.

components/SpringCursor.tsx
"use client";

import { useEffect } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";

export function SpringCursor() {
  const x = useMotionValue(-100);
  const y = useMotionValue(-100);
  const ringX = useSpring(x, { stiffness: 300, damping: 30, mass: 0.5 });
  const ringY = useSpring(y, { stiffness: 300, damping: 30, mass: 0.5 });

  useEffect(() => {
    const onMove = (e: PointerEvent) => {
      x.set(e.clientX);
      y.set(e.clientY);
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    return () => window.removeEventListener("pointermove", onMove);
  }, [x, y]);

  return (
    <div aria-hidden="true" className="pointer-events-none fixed inset-0 z-[100]">
      <motion.div
        style={{ x: ringX, y: ringY }}
        className="absolute -left-[18px] -top-[18px] h-9 w-9 rounded-full border border-zinc-900/50"
      />
      <motion.div
        style={{ x, y }}
        className="absolute -left-1 -top-1 h-2 w-2 rounded-full bg-zinc-900"
      />
    </div>
  );
}

Step 2: Grow the custom cursor over links

When the pointer is over a link or button, enlarge the ring and hide the dot so clickable spots stand out.

Attaching a listener to every link gets unmanageable as the page grows. Instead, attach one pointerover listener to document and inspect the element under the pointer. Handling events on a shared parent like this is called event delegation.

components/CustomCursor.tsx (additions and changes)
// Add inside CustomCursor
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  const root = rootRef.current;
  if (!root) return;

  const growSelector = "a, button, [role='button'], [data-cursor='grow']";
  const textSelector = "input, textarea, select, [contenteditable='true']";

  const onOver = (e: PointerEvent) => {
    const el = e.target instanceof Element ? e.target : null;
    root.dataset.hover = el?.closest(growSelector) ? "true" : "false";
    root.dataset.text = el?.closest(textSelector) ? "true" : "false";
  };

  document.addEventListener("pointerover", onOver);
  return () => document.removeEventListener("pointerover", onOver);
}, []);

// The returned JSX (add ref and group to the outer div)
<div
  ref={rootRef}
  aria-hidden="true"
  className="group pointer-events-none fixed inset-0 z-[100] transition-opacity data-[text=true]:opacity-0"
>
  <div ref={ringRef} className="absolute left-0 top-0">
    <div className="h-9 w-9 -translate-x-1/2 -translate-y-1/2 rounded-full border border-zinc-900/50 transition-transform duration-200 group-data-[hover=true]:scale-[1.8]" />
  </div>
  <div ref={dotRef} className="absolute left-0 top-0">
    <div className="h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-900 transition-opacity group-data-[hover=true]:opacity-0" />
  </div>
</div>

Opt more elements in with a data attribute

To grow the cursor over elements other than a and button, add data-cursor="grow" to them. It works well on project cards.

The check uses closest, so it still fires over icons or text nested inside a link.

Let CSS handle the size change

JavaScript only updates data-hover on the outer div. Scaling and fading are handled by Tailwind's group-data-[hover=true]: variant, and transition-transform makes the ring grow over 0.2 seconds.

Over text fields, data-text becomes true and the whole custom cursor fades out. Step 4 explains why.

Step 3: Turn the custom cursor off on touch devices and for reduced motion

Touch devices have no mouse cursor. Left as is, the ring can linger wherever someone tapped.

So read CSS media queries (conditions based on the screen or device) from JavaScript. There are three conditions:

  • hover: hover: the pointer can rest over elements, like a mouse

  • pointer: fine: the pointer is precise, like a mouse or trackpad

  • prefers-reduced-motion: no-preference: the user has not asked for less motion

hooks/useCustomCursorEnabled.ts
"use client";

import { useEffect, useState } from "react";

const QUERY =
  "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";

export function useCustomCursorEnabled() {
  const [enabled, setEnabled] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia(QUERY);
    const update = () => setEnabled(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  return enabled;
}

Render the custom cursor only when the conditions match

CustomCursor runs its logic and renders only while this value is true. When it is false, nothing renders and the native cursor stays.

It also adds a has-custom-cursor class to the html element, which is what decides whether to hide the native cursor.

The initial value is false so the server-rendered HTML matches the first client render. The cursor appears only after the browser confirms the conditions.

components/CustomCursor.tsx (changes)
// Only the changes to CustomCursor
import { useCustomCursorEnabled } from "@/hooks/useCustomCursorEnabled";

export function CustomCursor() {
  const enabled = useCustomCursorEnabled();
  // dotRef, ringRef, and rootRef stay the same

  useEffect(() => {
    document.documentElement.classList.toggle("has-custom-cursor", enabled);
    return () => document.documentElement.classList.remove("has-custom-cursor");
  }, [enabled]);

  useEffect(() => {
    if (!enabled) return;
    // The Step 1 follow logic (unchanged)
  }, [enabled]);

  useEffect(() => {
    if (!enabled) return;
    // The Step 2 hover detection (unchanged)
  }, [enabled]);

  if (!enabled) return null;

  // The return (JSX) below is the same as Step 2
}

Step 4: Mount the custom cursor in layout.tsx and keep the native cursor in text fields

Hiding the native arrow is optional. Adding just the ring while keeping the arrow is already a nice effect.

If you do hide it, set cursor: none only while has-custom-cursor is present, and bring back the native cursor in text fields, where it shows where typing will go.

app/globals.css
/* Hide the native cursor only while the custom cursor is active */
html.has-custom-cursor,
html.has-custom-cursor * {
  cursor: none;
}

/* Bring the native cursor back wherever people type */
html.has-custom-cursor :is(input, textarea, select, [contenteditable="true"]) {
  cursor: auto;
}

Keep the native cursor in text fields

In text fields, people rely on the thin I-beam cursor to see where they are clicking. Replace that with a round ring and they lose track of the caret position.

That is why Step 2 hides the custom cursor over fields and this CSS restores the native one.

Mount it once in layout.tsx

Place a single custom cursor in the root layout.tsx. It is not recreated on navigation, so the ring never jumps.

layout.tsx itself does not need "use client"; only the CustomCursor file does.

app/layout.tsx
import { CustomCursor } from "@/components/CustomCursor";
import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <CustomCursor />
      </body>
    </html>
  );
}

Common custom cursor mistakes and a pre-launch checklist

  • Forgetting pointer-events-none, so links stop working

  • Moving it with left and top, which makes the motion heavy

  • Rendering the ring on phones, where it lingers at the last tap

  • Showing the trailing motion to people who prefer reduced motion

  • Hiding the native cursor in text fields, so people cannot see where they are typing

  • Too much lag, so it is unclear what the cursor is pointing at

  • Adding it to work-focused screens. Keep the effect to showcase pages

Before launch, try it with a mouse, a trackpad, and a phone. Also test with your OS's reduce motion setting turned on.

Copy sections to pair with your custom cursor

The DesignLayer component catalog, filterable by use case in the left sidebar
The DesignLayer component catalog, filterable by use case in the left sidebar

A custom cursor only shines when there is something for it to react to. Pair it with cards and buttons that move on hover for a consistent effect.

In the DesignLayer catalog, you can check animated sections in the live preview. Browsing and previews are free, with no account or card needed.

When you find one you like, press Copy for Cursor on its page. It bundles the integration instructions, the live preview URL, the component TSX, and npm dependencies, ready to paste into Cursor or Claude.

The free plan includes 3 source copies per day (JST). Open the catalog at https://design-layer.com/en/components.

Buttons that pull toward the cursor are covered in https://design-layer.com/en/articles/nextjs-magnetic-button-guide.

Cards that move on hover are covered in https://design-layer.com/en/articles/nextjs-hover-card-guide.

Custom cursor FAQ

Can't I make a custom cursor with just CSS cursor: url()?

If you only want to swap in an image, cursor: url() does it with no JavaScript.

But the image moves exactly with the mouse, like the native cursor. You cannot get a trailing ring or a smooth grow effect that way.

requestAnimationFrame or Motion springs: which should I use?

If you already use Motion, springs are shorter to write. If you would rather not add a dependency, requestAnimationFrame is plenty.

Both update transform, so there is no big difference in performance.

Does a custom cursor affect SEO or performance?

The cursor is decoration, so it does not change what search engines read.

Performance is rarely a concern as long as you move it with transform. It gets heavy if you move it with left and top, or update React state on every move.

Should I hide the native cursor?

Not necessarily. Keeping the native arrow and adding a ring works fine as an effect.

If you hide it, bring it back in text fields, and never hide it on touch devices or for people who prefer reduced motion.

Summary: how to build a custom cursor in Next.js

  • A custom cursor suits showcase sites like portfolios; keep it off work-focused screens

  • Move an element laid over the whole screen to the mouse position with transform

  • Add pointer-events-none so it never blocks clicks

  • Create the ring's lag with requestAnimationFrame or Motion springs

  • Detect links with a single pointerover listener on document to grow the ring

  • Switch it off on touch devices and for reduced motion, and keep the native cursor in text fields

Start by mounting the Step 1 code in layout.tsx and moving your mouse around. Buttons that react to the cursor are covered in https://design-layer.com/en/articles/nextjs-magnetic-button-guide.

Next.js Custom Cursor: Build a Smooth Dot and Ring Follower | DesignLayer