1. TOP
  2. コムセント 技術情報
  3. JavaScript でカーソルを追いかけて動く要素を作る

JavaScript でカーソルを追いかけて動く要素を作る

ひと昔はカーソルをサイト独自のアイコンに変えたりしたサイトを見かけたものですが、さらに凝ったものとしてカーソルをアイコンなどが追いかけてくるような演出も見かけたことがあります。ちょうどゲームで主人公を追いかけてくる追尾弾のような、あの動きです。

最近あまり素の JavaScript を書く機会がなかったので、頭の体操がてら AI に頼らずあの動きを実装してみました。

探せば絶対に便利なライブラリがあるはずですが、今回はコピペで済むように素の JavaScript で実装してみます。

お急ぎの方へ

以下の class がいわゆるライブラリ部分です。これをまるごとコピペして、自身の HTML に組み込んでください。 コピペした後の使い方はすぐ下の一番単純な用例を参考にして下さい。

一番単純な用例

以下は、カーソルを追いかける赤い円を表示する HTML の例です。

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>カーソル追跡 DEMO</title>
<style>
.circle {
transition: top, left 0.1s linear;
position: absolute;
width: 50px;
height: 50px;
border-radius: 50%;
pointer-events: none; /* クリックを無視 */
transform: translate(-50%, -50%); /* 中心に配置 */
}
</style>
</head>
<body>
<h1>カーソル追跡 DEMO</h1>
<div id="circle1" class="circle" style="background-color: red;"></div>
<script defer>
class Tracing {
// 1mm 秒間に追跡物が進む距離 (px)
#speed;

// 前回のループ実行時間
#beforeTimestamp = 0;

// 追跡物とその座標配列
#tracers = new Map();

// 追跡対象の現 X 座標
#targetX = 0;

// 追跡対象の現 Y 座標
#targetY = 0;

// 反発させるかどうか
#repulsion = false;

// 反発させる距離の閾値
#maxDistance = 9999;

// アニメーションループの開始
constructor(speed) {
const loop = () => {
// 現在の時間を取得
const timestamp = Date.now();

// 前回のループからの経過時間を計算
const delta = timestamp - this.#beforeTimestamp;

// 今回のループ時間を保存
this.#beforeTimestamp = timestamp;

// 追跡物をすべて更新
this.#tracers.forEach((parameter) => {
// 追跡物の現在位置を取得
const {x: tracerX, y: tracerY, listener} = parameter;

// 追跡物と追跡対象の距離を計算
const dx = this.#targetX - tracerX;
const dy = this.#targetY - tracerY;
const distance = Math.sqrt(dx * dx + dy * dy);

// 1 フレームで進む距離を計算
const distancePerFrame = this.#speed * delta;

// 追跡対象に向かう単位ベクトルを計算
if (distance > distancePerFrame) {
// 反発させる場合、閾値以内なら追跡対象から遠ざける
const newX = tracerX + dx / distance * distancePerFrame * (this.#repulsion && distance < this.#maxDistance ? -1 : 1);
const newY = tracerY + dy / distance * distancePerFrame * (this.#repulsion && distance < this.#maxDistance ? -1 : 1);

// 追跡物の位置を更新
parameter.x = newX;
parameter.y = newY;

// リスナー関数を呼び出し
listener(newX, newY);
}

// 追跡対象に直接移動
else {
parameter.x = this.#targetX;
parameter.y = this.#targetY;

// リスナー関数を呼び出し
listener(this.#targetX, this.#targetY);
}
});

// 次のループを予約
requestAnimationFrame(loop);
};

// スピードの設定
this.#speed = speed;

// ループ 1 回目が参照する時間を設定
this.#beforeTimestamp = Date.now();

// requestAnimationFrame でループを開始
requestAnimationFrame(loop);
}

// 1mm 秒間に追跡物が進む距離 (px) を設定
setSpeed(speed) {
this.#speed = speed;
}

// 追跡物と、次のフレームでその追跡物が移動するべき座標を引数に取る関数を登録
setTracer(element, listener, x = 0, y = 0) {
this.#tracers.set(element, {listener, x, y});
}

// 追跡物の現在位置を設定
setTracerPosition(element, x, y, relative = false) {
const tracer = this.#tracers.get(element);
if (tracer) {
tracer.x = relative ? tracer.x + x : x;
tracer.y = relative ? tracer.y + y : y;
}
}

// 登録されている追跡物を削除
removeTracer(element) {
this.#tracers.delete(element);
}

// 追跡対象の座標を更新
setTarget(x, y) {
this.#targetX = x;
this.#targetY = y;
}

// 反発させるかどうかを取得
getRepulsion() {
return this.#repulsion;
}

// 反発させるかどうかを設定
setRepulsion(repulsion) {
this.#repulsion = repulsion;
}

// 反発させる距離の閾値を設定
setMaxDistance(maxDistance) {
this.#maxDistance = maxDistance;
}
};

// Tracing インスタンスを作成 (1mm 秒間に 0.5px 進む)
const tracing = new Tracing(1.5);

// マウスが動いたときに追跡対象を更新
window.addEventListener('mousemove', (event) => {
tracing.setTarget(event.clientX, event.clientY);
});

// 追従させる円
const circle1 = document.getElementById('circle1');

// 追従させる円を登録
tracing.setTracer(circle1, (x, y) => {
circle1.style.left = `${x}px`;
circle1.style.top = `${y}px`;
});
</script>
</body>
</html>

大抵の場合、この JavaScript 部分のうち、

  class Tracing { /*略*/ }

// Tracing インスタンスを作成 (1mm 秒間に 0.5px 進む)
const tracing = new Tracing(1.5);

// マウスが動いたときに追跡対象を更新
window.addEventListener('mousemove', (event) => {
tracing.setTarget(event.clientX, event.clientY);
});
まではコピペで良いでしょう。
後半の、
  // 追従させる円
const circle1 = document.getElementById('circle1');

// 追従させる円を登録
tracing.setTracer(circle1, (x, y) => {
circle1.style.left = `${x}px`;
circle1.style.top = `${y}px`;
});
部分では、追従させたい要素を取得して `setTracer` メソッドで登録しています。 `const circle1 = ` の部分を追従させたい要素に書き換え、 `tracing.setTracer` の部分で追従させたい要素のスタイルを更新するように書き換えてください。

もし、追従するスピードを変えたい場合は、 `new Tracing(1.5);` の `1.5` の部分を書き換えてください。数値が大きいほど速く追従します。

もう少し凝った用例

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>カーソル追跡 DEMO</title>
<style>
.circle {
transition: top, left 0.1s linear;
position: absolute;
width: 50px;
height: 50px;
border-radius: 50%;
pointer-events: none; /* クリックを無視 */
transform: translate(-50%, -50%); /* 中心に配置 */
}
</style>
</head>
<body>
<h1>カーソル追跡 DEMO</h1>
<div id="circle1" class="circle" style="background-color: red;"></div>
<div id="circle2" class="circle" style="background-color: blue;"></div>
<div id="circle3" class="circle" style="background-color: green;"></div>
<div id="circle4" class="circle" style="background-color: yellow;"></div>
<div id="circle5" class="circle" style="background-color: purple;"></div>
<script defer>
class Tracing {
// 1mm 秒間に追跡物が進む距離 (px)
#speed;

// 前回のループ実行時間
#beforeTimestamp = 0;

// 追跡物とその座標配列
#tracers = new Map();

// 追跡対象の現 X 座標
#targetX = 0;

// 追跡対象の現 Y 座標
#targetY = 0;

// 反発させるかどうか
#repulsion = false;

// 反発させる距離の閾値
#maxDistance = 9999;

// アニメーションループの開始
constructor(speed) {
const loop = () => {
// 現在の時間を取得
const timestamp = Date.now();

// 前回のループからの経過時間を計算
const delta = timestamp - this.#beforeTimestamp;

// 今回のループ時間を保存
this.#beforeTimestamp = timestamp;

// 追跡物をすべて更新
this.#tracers.forEach((parameter) => {
// 追跡物の現在位置を取得
const {x: tracerX, y: tracerY, listener} = parameter;

// 追跡物と追跡対象の距離を計算
const dx = this.#targetX - tracerX;
const dy = this.#targetY - tracerY;
const distance = Math.sqrt(dx * dx + dy * dy);

// 1 フレームで進む距離を計算
const distancePerFrame = this.#speed * delta;

// 追跡対象に向かう単位ベクトルを計算
if (distance > distancePerFrame) {
// 反発させる場合、閾値以内なら追跡対象から遠ざける
const newX = tracerX + dx / distance * distancePerFrame * (this.#repulsion && distance < this.#maxDistance ? -1 : 1);
const newY = tracerY + dy / distance * distancePerFrame * (this.#repulsion && distance < this.#maxDistance ? -1 : 1);

// 追跡物の位置を更新
parameter.x = newX;
parameter.y = newY;

// リスナー関数を呼び出し
listener(newX, newY);
}

// 追跡対象に直接移動
else {
parameter.x = this.#targetX;
parameter.y = this.#targetY;

// リスナー関数を呼び出し
listener(this.#targetX, this.#targetY);
}
});

// 次のループを予約
requestAnimationFrame(loop);
};

// スピードの設定
this.#speed = speed;

// ループ 1 回目が参照する時間を設定
this.#beforeTimestamp = Date.now();

// requestAnimationFrame でループを開始
requestAnimationFrame(loop);
}

// 1mm 秒間に追跡物が進む距離 (px) を設定
setSpeed(speed) {
this.#speed = speed;
}

// 追跡物と、次のフレームでその追跡物が移動するべき座標を引数に取る関数を登録
setTracer(element, listener, x = 0, y = 0) {
this.#tracers.set(element, {listener, x, y});
}

// 追跡物の現在位置を設定
setTracerPosition(element, x, y, relative = false) {
const tracer = this.#tracers.get(element);
if (tracer) {
tracer.x = relative ? tracer.x + x : x;
tracer.y = relative ? tracer.y + y : y;
}
}

// 登録されている追跡物を削除
removeTracer(element) {
this.#tracers.delete(element);
}

// 追跡対象の座標を更新
setTarget(x, y) {
this.#targetX = x;
this.#targetY = y;
}

// 反発させるかどうかを取得
getRepulsion() {
return this.#repulsion;
}

// 反発させるかどうかを設定
setRepulsion(repulsion) {
this.#repulsion = repulsion;
}

// 反発させる距離の閾値を設定
setMaxDistance(maxDistance) {
this.#maxDistance = maxDistance;
}
};

// Tracing インスタンスを作成 (1mm 秒間に 0.5px 進む)
const tracing = new Tracing(1.5);

// マウスが動いたときに追跡対象を更新
window.addEventListener('mousemove', (event) => {
tracing.setTarget(event.clientX, event.clientY);
});

// 追従させる円
const circle1 = document.getElementById('circle1');
const circle2 = document.getElementById('circle2');
const circle3 = document.getElementById('circle3');
const circle4 = document.getElementById('circle4');
const circle5 = document.getElementById('circle5');

// 追従させる円を登録
tracing.setTracer(circle1, (x, y) => {
circle1.style.left = `${x}px`;
circle1.style.top = `${y}px`;
}, 0, 0);
tracing.setTracer(circle2, (x, y) => {
circle2.style.left = `${x}px`;
circle2.style.top = `${y}px`;
}, 200, 0);
tracing.setTracer(circle3, (x, y) => {
circle3.style.left = `${x}px`;
circle3.style.top = `${y}px`;
}, 0, 200);
tracing.setTracer(circle4, (x, y) => {
circle4.style.left = `${x}px`;
circle4.style.top = `${y}px`;
}, 200, 200);
tracing.setTracer(circle5, (x, y) => {
circle5.style.left = `${x}px`;
circle5.style.top = `${y}px`;
}, 100, 100);

// クリックで反発モードを切り替え
window.addEventListener('click', () => {
tracing.setTracerPosition(circle1, Math.random() * 100 - 50, Math.random() * 100 - 50, true);
tracing.setTracerPosition(circle2, Math.random() * 100 - 50, Math.random() * 100 - 50, true);
tracing.setTracerPosition(circle3, Math.random() * 100 - 50, Math.random() * 100 - 50, true);
tracing.setTracerPosition(circle4, Math.random() * 100 - 50, Math.random() * 100 - 50, true);
tracing.setTracerPosition(circle5, Math.random() * 100 - 50, Math.random() * 100 - 50, true);

tracing.setRepulsion(!tracing.getRepulsion());
});
</script>
</body>
</html>

この例では、5 つの円がカーソルを追いかけます。さらに、クリックするたびに円がランダムに少し動き、反発モードが切り替わります。反発モードでは、円がカーソルから遠ざかるようになります。カーソルから遠ざかる限界値は setMaxDistance メソッドで設定できます (デフォルトは 9999px)。

さらに凝って Canvas で描画する用例

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>カーソル追跡 DEMO (Canvas)</title>
<style>
body {
margin: 0;
overflow: hidden;
}
canvas {
display: block;
background: #f0f0f0;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
class Tracing {
// 1mm 秒間に追跡物が進む距離 (px)
#speed;

// 前回のループ実行時間
#beforeTimestamp = 0;

// 追跡物とその座標配列
#tracers = new Map();

// 追跡対象の現 X 座標
#targetX = 0;

// 追跡対象の現 Y 座標
#targetY = 0;

// 反発させるかどうか
#repulsion = false;

// 反発させる距離の閾値
#maxDistance = 9999;

// アニメーションループの開始
constructor(speed) {
const loop = () => {
// 現在の時間を取得
const timestamp = Date.now();

// 前回のループからの経過時間を計算
const delta = timestamp - this.#beforeTimestamp;

// 今回のループ時間を保存
this.#beforeTimestamp = timestamp;

// 追跡物をすべて更新
this.#tracers.forEach((parameter) => {
// 追跡物の現在位置を取得
const {x: tracerX, y: tracerY, listener} = parameter;

// 追跡物と追跡対象の距離を計算
const dx = this.#targetX - tracerX;
const dy = this.#targetY - tracerY;
const distance = Math.sqrt(dx * dx + dy * dy);

// 1 フレームで進む距離を計算
const distancePerFrame = this.#speed * delta;

// 追跡対象に向かう単位ベクトルを計算
if (distance > distancePerFrame) {
// 反発させる場合、閾値以内なら追跡対象から遠ざける
const newX = tracerX + dx / distance * distancePerFrame * (this.#repulsion && distance < this.#maxDistance ? -1 : 1);
const newY = tracerY + dy / distance * distancePerFrame * (this.#repulsion && distance < this.#maxDistance ? -1 : 1);

// 追跡物の位置を更新
parameter.x = newX;
parameter.y = newY;

// リスナー関数を呼び出し
listener(newX, newY);
}

// 追跡対象に直接移動
else {
parameter.x = this.#targetX;
parameter.y = this.#targetY;

// リスナー関数を呼び出し
listener(this.#targetX, this.#targetY);
}
});

// 次のループを予約
requestAnimationFrame(loop);
};

// スピードの設定
this.#speed = speed;

// ループ 1 回目が参照する時間を設定
this.#beforeTimestamp = Date.now();

// requestAnimationFrame でループを開始
requestAnimationFrame(loop);
}

// 1mm 秒間に追跡物が進む距離 (px) を設定
setSpeed(speed) {
this.#speed = speed;
}

// 追跡物と、次のフレームでその追跡物が移動するべき座標を引数に取る関数を登録
setTracer(element, listener, x = 0, y = 0) {
this.#tracers.set(element, {listener, x, y});
}

// 追跡物の現在位置を設定
setTracerPosition(element, x, y, relative = false) {
const tracer = this.#tracers.get(element);
if (tracer) {
tracer.x = relative ? tracer.x + x : x;
tracer.y = relative ? tracer.y + y : y;
}
}

// 登録されている追跡物を削除
removeTracer(element) {
this.#tracers.delete(element);
}

// 追跡対象の座標を更新
setTarget(x, y) {
this.#targetX = x;
this.#targetY = y;
}

// 反発させるかどうかを取得
getRepulsion() {
return this.#repulsion;
}

// 反発させるかどうかを設定
setRepulsion(repulsion) {
this.#repulsion = repulsion;
}

// 反発させる距離の閾値を設定
setMaxDistance(maxDistance) {
this.#maxDistance = maxDistance;
}
};

// --- Canvas 初期化 ---
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

// Tracing インスタンスを作成 (1mm 秒間に 0.5px 進む)
const tracing = new Tracing(1.5);

// 円のデータ
const circles = [
{color: 'red', radius: 25},
{color: 'blue', radius: 25},
{color: 'green', radius: 25},
{color: 'yellow', radius: 25},
{color: 'purple', radius: 25},
];

// 描画用の位置情報
circles.forEach((circle, i) => {
tracing.setTracer(circle, (x, y) => {
circle.x = x;
circle.y = y;
}, 100 + i * 80, 100 + i * 60);
});

// アニメーション描画
function render() {
// アニメーションのためにキャンバスをクリア
ctx.clearRect(0, 0, canvas.width, canvas.height);

// 経過秒をラジアンに変換
const rad = Date.now() / 150;

// カンバス中央から半径 300 の円を描くようにターゲットを移動
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const targetX = centerX + Math.cos(rad) * 300;
const targetY = centerY + Math.sin(rad) * 300;
tracing.setTarget(targetX, targetY);

// 追跡対象を描画
ctx.beginPath();
ctx.arc(targetX, targetY, 10, 0, Math.PI * 2);
ctx.fillStyle = 'black';
ctx.fill();

// 各円を描画
circles.forEach((c) => {
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
ctx.fillStyle = c.color;
ctx.fill();
});

// 次のフレームを予約
requestAnimationFrame(render);
}
render();

// クリックで反発モード切替
window.addEventListener('click', () => {
circles.forEach(c => {
tracing.setTracerPosition(c, Math.random() * 500 - 250, Math.random() * 500 - 250, true);
});
tracing.setRepulsion(!tracing.getRepulsion());
});

// ウィンドウリサイズ対応
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resize);
resize();
</script>
</body>
</html>

この例では、Canvas を使って描画しています。さらに、追いかける対象がマウスカーソルではなく、画面中央を中心に円を描く黒い点となっています。

Tracing クラスは DOM に依存していないので、 Canvas の描画にもそのまま使えます。 setTracer メソッドの第一引数に任意のオブジェクトを渡し、第二引数のリスナー関数でそのオブジェクトの位置情報を更新するようにしています。

まとめ

そんなに難しいコードではないので、必要に応じて改造することもできると思います。パフォーマンスは怪しいですが、ゲームなどにも転用できるかもしれません。

プログラマー / N.Go

CodeIgniterやLaravel、Vue.jsといったフレームワークを用い、ECシステム、リアルタイム課金制生配信、掲示板ライクなSNSシステムなどのWebシステム制作に携わる。 プロジェクトによってはフロントエンドも一貫して請け負う。

このメンバーの記事一覧へ