【Next.js】カスタムカーソルの作り方を追従アニメーションまで解説
公開日: 2026年10月8日 · 読了目安 9 分
Next.js のポートフォリオなどに、マウスに付いてくるカスタムカーソルを置く手順です。ドットとリングの追従、requestAnimationFrame と Motion の spring の2通りの書き方、リンクの上で大きくする方法、スマホと動きを減らす設定での止め方まで、コード付きで解説します。
マウスに付いてくるカスタムカーソルを Next.js で作る
Next.js でポートフォリオを作っていて、他の作品サイトのようにマウスに丸いリングが付いてくる演出を入れたい。でも、どこから手を付ければいいか分からない。
そんなときの答えは、画面全体に重ねた小さな部品を、マウスの位置に合わせて動かすことです。
マウスの位置はブラウザでしか分からないので、カーソルのファイルには "use client" を付けます。これはブラウザで動かす印です。
動かすときは位置の値ではなく transform(要素を移動・拡大する CSS)を書き換えます。こうすると、動きがカクつきにくくなります。
この記事で分かることは次のとおりです。
カスタムカーソルが向くサイトと、向かないサイト
ドットとリングが追いかけるカーソルの作り方(2通り)
リンクやボタンの上でリングを大きくする方法
スマホと動きを減らす設定で、カーソルの演出を止める方法
入力欄で標準のカーソルを残す CSS
カスタムカーソルが向くサイトと向かないサイト
カスタムカーソルは、見た目の印象を大事にするサイトで効果があります。例えば次のようなサイトです。
デザイナーや写真家のポートフォリオ
ブランドやキャンペーンの特設ページ
作品を大きく見せるギャラリー
理由は2つあります。1つ目は、マウスを動かすだけで「作り込まれたサイト」だと伝わることです。
2つ目は、押せる場所を目立たせられることです。リンクの上でリングが大きくなれば、そこが押せると分かります。
反対に、管理画面や EC の購入画面のように作業をするページには向きません。いつもと違うカーソルは、正確にクリックしたい人の邪魔になります。
また、スマホやタブレットにはマウスのカーソルがありません。指で触る端末では、この演出を出さないようにします。
Step1:ドットとリングが追いかけるカスタムカーソルを作る
小さなドットはマウスにぴったり付け、大きなリングは少し遅れて追いかけるようにします。この遅れが、なめらかな印象を作ります。
リングの位置は、requestAnimationFrame で画面の描き替えごとに少しずつ近づけます。requestAnimationFrame は、次の描画の直前に処理を呼ぶブラウザの仕組みです。
"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>
);
}コードのポイント
外側の div に pointer-events-none を付け、カーソルの部品がクリックを奪わないようにする
位置は left や top ではなく transform で動かす。ページの配置を計算し直さないので軽い
リングは毎フレーム、残りの距離の 20% だけ近づける。0.2 を小さくすると遅れが大きくなる
内側の div で -translate-x-1/2 を使い、円の中心をマウスの先に合わせる
マウスが動くまでは画面の外(-100px)に置いておく
カーソルの部品は飾りなので、aria-hidden を付けて読み上げの対象から外しています。
Motion の spring でカスタムカーソルを書く場合
Motion(旧 Framer Motion)を使っているなら、useSpring でリングの遅れを作れます。spring は、ばねのように追いかけて止まる動きです。
stiffness(硬さ)を下げると遅れが大きくなり、damping(揺れの止まりやすさ)を下げると、行き過ぎて戻る揺れが出ます。
Motion の値は React の再描画を起こさずに位置を書き換えるので、こちらも軽く動きます。
"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>
);
}Step2:リンクの上でカスタムカーソルを大きくする
リンクやボタンの上に来たら、リングを大きくしてドットを消します。押せる場所が分かりやすくなります。
リンク1つずつにイベントを付けると、数が増えたときに管理しきれません。そこで document に1つだけ pointerover を付け、マウスの下の要素を調べます。
このように親でまとめてイベントを受ける方法を、イベント委任と呼びます。
// 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);
}, []);
// return の JSX(外側の div に ref と group を付ける)
<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>data 属性で反応させる場所を増やす
a と button 以外の要素で大きくしたいときは、その要素に data-cursor="grow" を付けます。作品のカードなどに使えます。
判定には closest を使っているので、リンクの中のアイコンや文字の上でも反応します。
大きさの切り替えは CSS に任せる
JavaScript は外側の div の data-hover を書き換えるだけです。拡大や透明化は、Tailwind の group-data-[hover=true]: で指定します。
transition-transform を付けているので、リングは 0.2 秒かけて大きくなります。
入力欄の上では data-text が true になり、カーソルの部品全体を消します。理由は Step4 で説明します。
Step3:スマホと動きを減らす設定でカスタムカーソルを止める
指で触る端末には、マウスのカーソルがありません。そのままだと、タップした場所にリングが残って見えることがあります。
そこで CSS のメディアクエリ(画面や端末の条件で処理を分ける仕組み)を JavaScript で読みます。条件は次の3つです。
hover: hover ……マウスのように、要素の上に乗せられる
pointer: fine ……マウスやトラックパッドのように、細かく指せる
prefers-reduced-motion: no-preference ……動きを減らす設定にしていない
"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;
}条件を満たすときだけカスタムカーソルを描く
CustomCursor では、この値が true のときだけ処理を動かし、部品を描きます。false なら何も描かず、標準のカーソルのままです。
同時に html 要素へ has-custom-cursor というクラスを付けます。標準のカーソルを隠すかどうかは、このクラスで切り替えます。
最初の値を false にしているのは、サーバーで作る HTML とずれないようにするためです。ブラウザで条件を確かめてから表示します。
// CustomCursor の変更点だけ
import { useCustomCursorEnabled } from "@/hooks/useCustomCursorEnabled";
export function CustomCursor() {
const enabled = useCustomCursorEnabled();
// dotRef・ringRef・rootRef は今までと同じ
useEffect(() => {
document.documentElement.classList.toggle("has-custom-cursor", enabled);
return () => document.documentElement.classList.remove("has-custom-cursor");
}, [enabled]);
useEffect(() => {
if (!enabled) return;
// Step1 の追従の処理(中身は同じ)
}, [enabled]);
useEffect(() => {
if (!enabled) return;
// Step2 のホバー判定(中身は同じ)
}, [enabled]);
if (!enabled) return null;
// この後の return(JSX)は Step2 と同じ
}Step4:カスタムカーソルを layout.tsx に置き、入力欄では標準に戻す
独自のカーソルを出すときに、標準の矢印を隠すかどうかは選べます。隠さずにリングだけ足す形でも、十分に演出になります。
隠す場合は、has-custom-cursor が付いているときだけ cursor: none にします。入力欄では、文字を打つ位置を示す標準のカーソルに戻します。
/* カスタムカーソルが有効なときだけ、標準のカーソルを隠す */
html.has-custom-cursor,
html.has-custom-cursor * {
cursor: none;
}
/* 文字を入力する場所では、標準のカーソルに戻す */
html.has-custom-cursor :is(input, textarea, select, [contenteditable="true"]) {
cursor: auto;
}入力欄では標準のカーソルを残す
入力欄では、文字を打つ位置を細い縦線のカーソルで確かめます。ここまで丸いリングにすると、どこをクリックしたか分からなくなります。
そのため Step2 では入力欄の上でカーソルの部品を消し、この CSS で標準のカーソルを戻しています。
layout.tsx に1つだけ置く
カーソルの部品は、ルートの layout.tsx に1つだけ置きます。ページを移動しても作り直されず、リングの位置が途切れません。
layout.tsx 自体には "use client" は要りません。印が必要なのは CustomCursor のファイルだけです。
import { CustomCursor } from "@/components/CustomCursor";
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
{children}
<CustomCursor />
</body>
</html>
);
}カスタムカーソルでよくある失敗と公開前チェック
pointer-events-none を忘れ、リンクが押せなくなる
left と top で位置を動かし、動きが重くなる
スマホでもリングを描き、タップした場所に残る
動きを減らす設定の人にも、遅れて付いてくる動きを見せる
入力欄でも標準のカーソルを隠し、文字を打つ位置が分からない
リングの遅れを大きくしすぎて、どこを指しているか分からない
作業用の画面にまで入れる。演出は見せるページだけにする
公開前には、マウス・トラックパッド・スマホの3つで触ってみてください。OS の「視差効果を減らす」や「アニメーションを減らす」をオンにした確認もしておきます。
カスタムカーソルと組み合わせるセクションをコピーして使う方法

カスタムカーソルは、カーソルが反応する先のセクションがあって初めて活きます。ホバーで動くカードやボタンと組み合わせると、演出がそろいます。
DesignLayer のカタログでは、動きのあるセクションをライブプレビューで確かめられます。閲覧とプレビューは無料で、アカウントもカードも要りません。
気に入った部品は、詳細ページの「Cursor 用にコピー」でコピーします。統合の指示・ライブプレビューの URL・本体の TSX・npm 依存がまとめて入り、Cursor や Claude に貼って使えます。
無料のソースコピーは日本時間で1日3回です。カタログは https://design-layer.com/ja/components から開けます。
カーソルに吸い寄せられるボタンは https://design-layer.com/ja/articles/nextjs-magnetic-button-guide で解説しています。
ホバーで動くカードは https://design-layer.com/ja/articles/nextjs-hover-card-guide で解説しています。
カスタムカーソルについてよくある質問
画像を1枚差し替えるだけなら、cursor: url() で作れます。JavaScript は要りません。
ただし画像は標準のカーソルと同じく、マウスにぴったり付いて動くだけです。リングが遅れて付いてくる動きや、なめらかな拡大はできません。
まとめ:Next.js でカスタムカーソルを作る手順
カスタムカーソルは、ポートフォリオなど見せるサイトに向く。作業用の画面には入れない
画面全体に重ねた部品を、transform でマウスの位置に動かす
pointer-events-none を付け、クリックを邪魔しない
リングの遅れは requestAnimationFrame か Motion の spring で作る
リンクの上での拡大は、document に1つ付けた pointerover で判定する
スマホと動きを減らす設定では止め、入力欄では標準のカーソルを残す
まずは Step1 のコードを layout.tsx に置き、マウスを動かしてみてください。カーソルに反応するボタンの作り方は https://design-layer.com/ja/articles/nextjs-magnetic-button-guide で解説しています。