追従カスタムカーソル

即時追従のドットと遅延追従のリングを線形補間(lerp)で重ねた2層カーソル。サイトのブランド演出やインタラクティブなナビに使えます。

#js#animation#lerp#requestAnimationFrame

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW: カフェのメニュー1画面。追従カーソル(ドット+遅延リング)を主役に -->
<div class="mb" data-cursor-root>
  <header class="mb__bar">
    <span class="mb__logo"><span class="mb__cup">☕</span>MOON BREW</span>
    <nav class="mb__nav">
      <a href="#" data-hover>メニュー</a>
      <a href="#" data-hover>店舗</a>
      <a href="#" data-hover>豆を買う</a>
    </nav>
  </header>

  <section class="mb__body">
    <div class="mb__intro">
      <p class="mb__kicker">TODAY'S BREW</p>
      <h1 class="mb__title">月夜に、<br>とっておきの一杯を。</h1>
      <p class="mb__lead">深煎りの香りとなめらかな泡。<br>静かな夜にそっと寄り添うコーヒー。</p>
      <span class="mb__order" data-hover>メニューを見る</span>
    </div>

    <ul class="mb__menu">
      <li class="mb__item" data-hover>
        <span class="mb__name">カフェラテ</span>
        <span class="mb__price">¥520</span>
      </li>
      <li class="mb__item" data-hover>
        <span class="mb__name">月見モカ</span>
        <span class="mb__price">¥580</span>
      </li>
      <li class="mb__item" data-hover>
        <span class="mb__name">琥珀ハニー</span>
        <span class="mb__price">¥560</span>
      </li>
    </ul>
  </section>

  <!-- 主役: 2層カーソル -->
  <div class="cursor-dot" data-dot></div>
  <div class="cursor-ring" data-ring></div>
</div>
CSS
/* MOON BREW カフェテーマ: クリーム/濃ブラウン/琥珀 */
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Hiragino Sans", "Yu Gothic", system-ui, sans-serif;
  background:
    radial-gradient(circle at 82% 16%, rgba(201,138,59,.22) 0%, transparent 44%),
    radial-gradient(circle at 8% 92%, rgba(43,29,18,.18) 0%, transparent 46%),
    #f5ede1;
  color: #2b1d12;
  overflow: hidden;
  cursor: none; /* 既定カーソルを隠して自作を主役に */
}

.mb {
  position: relative;
  height: 400px;
  display: flex;
  flex-direction: column;
  padding: 0 28px;
}

/* ヘッダー */
.mb__bar {
  display: flex;
  align-items: center;
  gap: 24px;
  padding: 16px 0;
  font-size: 14px;
}
.mb__logo {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-weight: 800;
  letter-spacing: .04em;
  font-size: 16px;
}
.mb__cup { font-size: 18px; }
.mb__nav {
  display: flex;
  gap: 20px;
  margin-left: auto;
}
.mb__nav a {
  color: #5a4632;
  text-decoration: none;
  position: relative;
}

/* 本文: 左コピー + 右メニュー */
.mb__body {
  flex: 1;
  display: grid;
  grid-template-columns: 1.15fr .85fr;
  gap: 26px;
  align-items: center;
}
.mb__kicker {
  margin: 0 0 8px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .22em;
  color: #c98a3b;
}
.mb__title {
  margin: 0 0 12px;
  font-size: 30px;
  line-height: 1.32;
  font-weight: 800;
}
.mb__lead {
  margin: 0 0 18px;
  font-size: 13px;
  line-height: 1.8;
  color: #6b5640;
}
.mb__order {
  display: inline-block;
  padding: 11px 22px;
  font-size: 13px;
  font-weight: 700;
  color: #f5ede1;
  background: linear-gradient(135deg, #2b1d12, #4a3320);
  border-radius: 999px;
  box-shadow: 0 10px 22px rgba(43,29,18,.28);
}

/* メニューカード */
.mb__menu {
  list-style: none;
  margin: 0;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  background: rgba(255,255,255,.66);
  border: 1px solid rgba(201,138,59,.28);
  border-radius: 16px;
  box-shadow: 0 14px 34px rgba(43,29,18,.12);
}
.mb__item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 11px 14px;
  border-radius: 11px;
  font-size: 14px;
  font-weight: 600;
  background: rgba(245,237,225,.6);
  transition: background .25s ease;
}
.mb__price { color: #c98a3b; font-weight: 700; }

/* 主役: 追従カーソル(ドット+リング) */
.cursor-dot,
.cursor-ring {
  position: fixed;
  top: 0;
  left: 0;
  pointer-events: none;
  z-index: 50;
  opacity: 0;
  transition: opacity .3s ease;
}
.mb.is-active .cursor-dot,
.mb.is-active .cursor-ring { opacity: 1; }
.cursor-dot {
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  border-radius: 50%;
  background: #2b1d12;
}
.cursor-ring {
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border-radius: 50%;
  border: 2px solid #c98a3b;
  transition: opacity .3s ease, width .3s ease, height .3s ease,
              margin .3s ease, background .3s ease;
}
.cursor-ring.is-hover {
  width: 56px;
  height: 56px;
  margin: -28px 0 0 -28px;
  background: rgba(201,138,59,.16);
}

@media (prefers-reduced-motion: reduce) {
  .cursor-dot, .cursor-ring { transition: opacity .2s ease; }
}
JavaScript
// MOON BREW: ドット即時+リング遅延の2層カーソル。待機中は自動巡回、操作で本物に追従
(() => {
  const root = document.querySelector('[data-cursor-root]');
  const dot = document.querySelector('[data-dot]');
  const ring = document.querySelector('[data-ring]');
  if (!root || !dot || !ring) return; // null安全

  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 目標(マウス/仮想)とリングの現在位置
  let targetX = window.innerWidth / 2;
  let targetY = window.innerHeight / 2;
  let ringX = targetX, ringY = targetY;
  let usePointer = false;   // 本物のポインタ追従中か
  let lastMove = 0;         // 最後にポインタが動いた時刻
  const IDLE = 1500;        // 無操作でこの時間が過ぎたら自動巡回へ戻る(ms)

  // 起動直後からカーソルを見せる(一覧プレビュー対策)
  root.classList.add('is-active');

  // マウス移動で目標更新し、ドットは即追従
  window.addEventListener('pointermove', (e) => {
    usePointer = true;
    lastMove = performance.now();
    targetX = e.clientX;
    targetY = e.clientY;
    root.classList.add('is-active');
  });
  window.addEventListener('pointerleave', () => { usePointer = false; });

  // ホバー対象でリング拡大
  document.querySelectorAll('[data-hover]').forEach((el) => {
    el.addEventListener('pointerenter', () => ring.classList.add('is-hover'));
    el.addEventListener('pointerleave', () => ring.classList.remove('is-hover'));
  });

  // 仮想カーソルの自動経路(リサージュ): メニュー周辺をゆっくり巡回
  const autoPos = (t) => {
    const r = root.getBoundingClientRect();
    const cx = r.left + r.width * 0.62;
    const cy = r.top + r.height * 0.52;
    return {
      x: cx + Math.sin(t * 0.00075) * r.width * 0.22,
      y: cy + Math.sin(t * 0.0011 + 0.8) * r.height * 0.2,
    };
  };

  const ease = reduce ? 1 : 0.18;
  const tick = (now) => {
    // 一定時間操作が無ければ自動巡回へ戻す
    if (usePointer && now - lastMove > IDLE) usePointer = false;
    if (!usePointer) {
      const p = autoPos(now);
      targetX = p.x;
      targetY = p.y;
    }
    // ドットは即時、リングは補間で遅延追従
    dot.style.transform = `translate(${targetX}px, ${targetY}px)`;
    ringX += (targetX - ringX) * ease;
    ringY += (targetY - ringY) * ease;
    ring.style.transform = `translate(${ringX}px, ${ringY}px)`;
    requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
})();

コード

HTML
<!-- 追従カスタムカーソル:リング(遅延追従)とドット(即時)の2層構成 -->
<div class="stage" data-cursor-root>
  <div class="content">
    <p class="kicker">CUSTOM CURSOR</p>
    <h1 class="title">追従カーソル</h1>
    <p class="lead">マウスを動かすと、リングが少し遅れてやさしく追いかけます。</p>
    <div class="chips">
      <span class="chip" data-hover>Design</span>
      <span class="chip" data-hover>Motion</span>
      <span class="chip" data-hover>Interaction</span>
    </div>
  </div>

  <!-- カーソル本体(2層) -->
  <div class="cursor-dot" data-dot></div>
  <div class="cursor-ring" data-ring></div>
</div>
CSS
/* ベース:暗めの上品な背景。iframe内なので body から自由に */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }

.stage {
  position: relative;
  height: 360px;
  display: grid;
  place-items: center;
  overflow: hidden;
  background:
    radial-gradient(1200px 400px at 70% -10%, #2b2256 0%, transparent 60%),
    radial-gradient(900px 500px at 0% 120%, #163a4f 0%, transparent 55%),
    #0d0f1a;
  color: #eef0ff;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  cursor: none; /* OS既定カーソルを隠す */
}

.content { text-align: center; padding: 24px; z-index: 1; }
.kicker {
  margin: 0 0 10px;
  letter-spacing: .42em;
  font-size: 11px;
  color: #8ea2ff;
  font-weight: 700;
}
.title {
  margin: 0 0 12px;
  font-size: clamp(34px, 7vw, 56px);
  font-weight: 800;
  letter-spacing: .02em;
  background: linear-gradient(90deg, #fff, #b8c2ff);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.lead { margin: 0 0 22px; color: #b9bedd; font-size: 14px; }

.chips { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.chip {
  padding: 9px 18px;
  border-radius: 999px;
  border: 1px solid rgba(142,162,255,.35);
  background: rgba(255,255,255,.04);
  color: #dfe3ff;
  font-size: 13px;
  font-weight: 600;
  transition: background .25s ease, transform .25s ease, border-color .25s ease;
}
.chip:hover {
  background: rgba(142,162,255,.18);
  border-color: rgba(142,162,255,.7);
  transform: translateY(-2px);
}

/* カーソル:ドット(即時)とリング(遅延追従) */
.cursor-dot, .cursor-ring {
  position: fixed;
  top: 0; left: 0;
  border-radius: 50%;
  pointer-events: none; /* クリックを邪魔しない */
  z-index: 9999;
  transform: translate(-50%, -50%);
  will-change: transform;
  /* 初回ポインタ移動まで非表示(中央の文字に重ならないように) */
  opacity: 0;
  transition: opacity .3s ease;
}
[data-cursor-root].is-active .cursor-dot,
[data-cursor-root].is-active .cursor-ring { opacity: 1; }
/* リングだけは個別 transition も維持 */
.cursor-ring {
  transition: width .25s ease, height .25s ease,
              border-color .25s ease, background .25s ease, opacity .3s ease;
}
.cursor-dot {
  width: 8px; height: 8px;
  background: #8ea2ff;
  box-shadow: 0 0 12px rgba(142,162,255,.9);
}
.cursor-ring {
  width: 38px; height: 38px;
  border: 2px solid rgba(142,162,255,.75);
}
/* ホバー時にリングを拡大して反転的に強調 */
.cursor-ring.is-hover {
  width: 64px; height: 64px;
  background: rgba(142,162,255,.12);
  border-color: #b8c2ff;
}

/* モーション控えめ設定:追従の遅延を切る */
@media (prefers-reduced-motion: reduce) {
  .chip { transition: none; }
}
JavaScript
// 追従カスタムカーソル:ドットは即時、リングは線形補間(lerp)で遅延追従
(() => {
  const root = document.querySelector('[data-cursor-root]');
  const dot = document.querySelector('[data-dot]');
  const ring = document.querySelector('[data-ring]');
  if (!root || !dot || !ring) return; // null安全

  // モーション控えめなら追従遅延を無効化
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 目標座標(マウス位置)と、リングの現在座標
  let mouseX = window.innerWidth / 2;
  let mouseY = window.innerHeight / 2;
  let ringX = mouseX;
  let ringY = mouseY;

  // マウス移動で目標を更新し、ドットは即追従
  window.addEventListener('pointermove', (e) => {
    mouseX = e.clientX;
    mouseY = e.clientY;
    dot.style.transform = `translate(${mouseX}px, ${mouseY}px) translate(-50%, -50%)`;
    // 初回移動でカーソルを表示(中央の文字との重なりを避ける)
    if (!root.classList.contains('is-active')) root.classList.add('is-active');
  });
  // 領域外に出たらカーソルを隠す
  window.addEventListener('pointerleave', () => root.classList.remove('is-active'));

  // ホバー対象でリングを拡大
  document.querySelectorAll('[data-hover]').forEach((el) => {
    el.addEventListener('pointerenter', () => ring.classList.add('is-hover'));
    el.addEventListener('pointerleave', () => ring.classList.remove('is-hover'));
  });

  // 描画ループ:リングを少しずつ目標へ近づける
  const ease = reduce ? 1 : 0.18;
  const tick = () => {
    ringX += (mouseX - ringX) * ease;
    ringY += (mouseY - ringY) * ease;
    ring.style.transform = `translate(${ringX}px, ${ringY}px) translate(-50%, -50%)`;
    requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「追従カスタムカーソル」の効果を追加してください。

# 追加してほしい効果
追従カスタムカーソル(カスタムカーソル)
即時追従のドットと遅延追従のリングを線形補間(lerp)で重ねた2層カーソル。サイトのブランド演出やインタラクティブなナビに使えます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 追従カスタムカーソル:リング(遅延追従)とドット(即時)の2層構成 -->
<div class="stage" data-cursor-root>
  <div class="content">
    <p class="kicker">CUSTOM CURSOR</p>
    <h1 class="title">追従カーソル</h1>
    <p class="lead">マウスを動かすと、リングが少し遅れてやさしく追いかけます。</p>
    <div class="chips">
      <span class="chip" data-hover>Design</span>
      <span class="chip" data-hover>Motion</span>
      <span class="chip" data-hover>Interaction</span>
    </div>
  </div>

  <!-- カーソル本体(2層) -->
  <div class="cursor-dot" data-dot></div>
  <div class="cursor-ring" data-ring></div>
</div>

【CSS】
/* ベース:暗めの上品な背景。iframe内なので body から自由に */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }

.stage {
  position: relative;
  height: 360px;
  display: grid;
  place-items: center;
  overflow: hidden;
  background:
    radial-gradient(1200px 400px at 70% -10%, #2b2256 0%, transparent 60%),
    radial-gradient(900px 500px at 0% 120%, #163a4f 0%, transparent 55%),
    #0d0f1a;
  color: #eef0ff;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  cursor: none; /* OS既定カーソルを隠す */
}

.content { text-align: center; padding: 24px; z-index: 1; }
.kicker {
  margin: 0 0 10px;
  letter-spacing: .42em;
  font-size: 11px;
  color: #8ea2ff;
  font-weight: 700;
}
.title {
  margin: 0 0 12px;
  font-size: clamp(34px, 7vw, 56px);
  font-weight: 800;
  letter-spacing: .02em;
  background: linear-gradient(90deg, #fff, #b8c2ff);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.lead { margin: 0 0 22px; color: #b9bedd; font-size: 14px; }

.chips { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.chip {
  padding: 9px 18px;
  border-radius: 999px;
  border: 1px solid rgba(142,162,255,.35);
  background: rgba(255,255,255,.04);
  color: #dfe3ff;
  font-size: 13px;
  font-weight: 600;
  transition: background .25s ease, transform .25s ease, border-color .25s ease;
}
.chip:hover {
  background: rgba(142,162,255,.18);
  border-color: rgba(142,162,255,.7);
  transform: translateY(-2px);
}

/* カーソル:ドット(即時)とリング(遅延追従) */
.cursor-dot, .cursor-ring {
  position: fixed;
  top: 0; left: 0;
  border-radius: 50%;
  pointer-events: none; /* クリックを邪魔しない */
  z-index: 9999;
  transform: translate(-50%, -50%);
  will-change: transform;
  /* 初回ポインタ移動まで非表示(中央の文字に重ならないように) */
  opacity: 0;
  transition: opacity .3s ease;
}
[data-cursor-root].is-active .cursor-dot,
[data-cursor-root].is-active .cursor-ring { opacity: 1; }
/* リングだけは個別 transition も維持 */
.cursor-ring {
  transition: width .25s ease, height .25s ease,
              border-color .25s ease, background .25s ease, opacity .3s ease;
}
.cursor-dot {
  width: 8px; height: 8px;
  background: #8ea2ff;
  box-shadow: 0 0 12px rgba(142,162,255,.9);
}
.cursor-ring {
  width: 38px; height: 38px;
  border: 2px solid rgba(142,162,255,.75);
}
/* ホバー時にリングを拡大して反転的に強調 */
.cursor-ring.is-hover {
  width: 64px; height: 64px;
  background: rgba(142,162,255,.12);
  border-color: #b8c2ff;
}

/* モーション控えめ設定:追従の遅延を切る */
@media (prefers-reduced-motion: reduce) {
  .chip { transition: none; }
}

【JavaScript】
// 追従カスタムカーソル:ドットは即時、リングは線形補間(lerp)で遅延追従
(() => {
  const root = document.querySelector('[data-cursor-root]');
  const dot = document.querySelector('[data-dot]');
  const ring = document.querySelector('[data-ring]');
  if (!root || !dot || !ring) return; // null安全

  // モーション控えめなら追従遅延を無効化
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 目標座標(マウス位置)と、リングの現在座標
  let mouseX = window.innerWidth / 2;
  let mouseY = window.innerHeight / 2;
  let ringX = mouseX;
  let ringY = mouseY;

  // マウス移動で目標を更新し、ドットは即追従
  window.addEventListener('pointermove', (e) => {
    mouseX = e.clientX;
    mouseY = e.clientY;
    dot.style.transform = `translate(${mouseX}px, ${mouseY}px) translate(-50%, -50%)`;
    // 初回移動でカーソルを表示(中央の文字との重なりを避ける)
    if (!root.classList.contains('is-active')) root.classList.add('is-active');
  });
  // 領域外に出たらカーソルを隠す
  window.addEventListener('pointerleave', () => root.classList.remove('is-active'));

  // ホバー対象でリングを拡大
  document.querySelectorAll('[data-hover]').forEach((el) => {
    el.addEventListener('pointerenter', () => ring.classList.add('is-hover'));
    el.addEventListener('pointerleave', () => ring.classList.remove('is-hover'));
  });

  // 描画ループ:リングを少しずつ目標へ近づける
  const ease = reduce ? 1 : 0.18;
  const tick = () => {
    ringX += (mouseX - ringX) * ease;
    ringY += (mouseY - ringY) * ease;
    ring.style.transform = `translate(${ringX}px, ${ringY}px) translate(-50%, -50%)`;
    requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。