リペリングエフェクトは、カーソルが近づくと要素が逃げるように動くインタラクションです。磁石の同極が反発する動きに似ています。

実装で押さえるべき点は3つです。反発ベクトルの向き(ここを間違えると吸い寄せられます)、requestAnimationFrame による間引き、そして prefers-reduced-motion への対応です。

この記事は2024年の公開後、2026年9月に見直しました。 公開当時の基本コードは反発ではなくカーソル方向に吸い寄せられる向きになっていたため修正し、Three.js の読み込み方も現行の方式に差し替えています。

  • 反発の向きは「カーソル → 要素」です。 「要素 → カーソル」で計算すると吸着になります
  • three.min.js は three.js r161 で削除されました。 現在は ES モジュールとして読み込みます

リペリングエフェクトとは

ポインターの位置を検知して、一定距離まで近づいたら要素をその反対方向へ逃がす演出です。距離が近いほど大きく逃げるようにすると、磁力のような自然な動きになります。

メリット 説明
視覚的な魅力 操作に対して即座に反応が返る
印象に残る 独自の動きでブランディングになる
導線の演出 触れてほしくない領域を直感的に示せる

一方で注意点もあります。ボタンやリンクに適用すると「押したいのに逃げる」という操作不能なUIになります。 装飾要素に限定してください。

 

基本実装

See the Pen
Repelling effect 01
by Rin (@rinblog0408)
on CodePen.

 

方向の計算:ここを間違えると吸い寄せられる

まず考え方を整理します。逃がしたいのは「カーソルから要素へ向かう向き」です。

// 要素の中心からカーソルへのベクトル
const dx = pointerX - centerX;
const dy = pointerY - centerY;

// これをそのまま使うと「カーソルの方へ動く」=吸着になる
transform = `translate(${Math.cos(angle) * power}px, ...)`;

// 反発させるには符号を反転する
transform = `translate(${-Math.cos(angle) * power}px, ...)`;

 

実際には三角関数を使わず、ベクトルを正規化する方が短く書けて速いです。

<div class="repel-area">
  <div class="circle"></div>
</div>

 

.repel-area {
  position: relative;
  height: 100vh;
  background: #222;
  overflow: hidden;
}

.circle {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 50px;
  height: 50px;
  margin: -25px 0 0 -25px;
  background: #f39c12;
  border-radius: 50%;
  transition: transform .25s ease-out;
  will-change: transform;
}

/* 動きを減らす設定なら反応させない */
@media (prefers-reduced-motion: reduce) {
  .circle { transition: none; }
}

 

const RADIUS = 120;   // 反応する距離
const STRENGTH = 60;  // 最大でどれだけ逃げるか

const circle = document.querySelector('.circle');
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

let pointerX = 0;
let pointerY = 0;
let ticking = false;

function update() {
  ticking = false;

  const rect = circle.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;

  // カーソル → 要素 のベクトル(この向きに逃がす)
  const dx = centerX - pointerX;
  const dy = centerY - pointerY;
  const distance = Math.hypot(dx, dy);

  if (distance > RADIUS || distance === 0) {
    circle.style.transform = 'translate(0, 0)';
    return;
  }

  // 近いほど強く、遠いほど弱く(0〜1)
  const ratio = (RADIUS - distance) / RADIUS;

  // 単位ベクトル × 強さ
  const moveX = (dx / distance) * STRENGTH * ratio;
  const moveY = (dy / distance) * STRENGTH * ratio;

  circle.style.transform = `translate(${moveX}px, ${moveY}px)`;
}

if (!reduceMotion) {
  document.addEventListener('pointermove', (e) => {
    pointerX = e.clientX;
    pointerY = e.clientY;

    // 1フレームに1回だけ計算する
    if (!ticking) {
      ticking = true;
      requestAnimationFrame(update);
    }
  });
}

 

解説

  • dx = centerX - pointerX(要素の中心からカーソルを引く):これが反発の向きです。逆にすると吸着になります
  • Math.hypot(dx, dy)Math.sqrt(dx*dx + dy*dy) と同じですが、こちらの方が意図が明確です
  • dx / distance で単位ベクトルにする:長さ1のベクトルに強さを掛けるので、角度計算(atan2cos/sin)が不要になります
  • ratio で距離に応じた減衰:境界で急に飛ばず、なめらかに効き始めます
  • distance === 0 の除外:カーソルが中心と完全に一致すると0除算で NaN になります

 

requestAnimationFrame で間引く理由

pointermove1秒間に100回以上発火することがあります。そのたびに getBoundingClientRect() を呼ぶとレイアウトの再計算が走り、要素数が増えるほど重くなります。

// 発火のたびに計算する(重い)
document.addEventListener('pointermove', (e) => {
  const rect = circle.getBoundingClientRect();
  // ...
});

// 座標だけ保存し、描画は1フレーム1回にする
document.addEventListener('pointermove', (e) => {
  pointerX = e.clientX;
  pointerY = e.clientY;
  if (!ticking) {
    ticking = true;
    requestAnimationFrame(update);
  }
});

 

ディスプレイの更新は最大でも毎秒60〜120回です。それ以上計算しても画面には反映されません。イベントハンドラでは座標の保存だけを行い、実際の計算は requestAnimationFrame に任せるのが定石です。

 

応用①:要素ごとに反発の強さを変える

See the Pen
Repelling effect 02
by Rin (@rinblog0408)
on CodePen.

 

大きい要素ほど動きにくくすると、質量があるように見えます。data-* 属性で個別に指定します。

<div class="repel-area">
  <div class="circle small"  data-radius="150" data-strength="80" style="top: 30%; left: 20%;"></div>
  <div class="circle medium" data-radius="120" data-strength="50" style="top: 50%; left: 50%;"></div>
  <div class="circle large"  data-radius="100" data-strength="25" style="top: 70%; left: 75%;"></div>
</div>

 

.circle { position: absolute; border-radius: 50%; transition: transform .25s ease-out; will-change: transform; }
.circle.small  { width: 40px;  height: 40px;  background: #e74c3c; }
.circle.medium { width: 70px;  height: 70px;  background: #3498db; }
.circle.large  { width: 100px; height: 100px; background: #2ecc71; }

 

const circles = [...document.querySelectorAll('.circle')].map((el) => ({
  el,
  radius: Number(el.dataset.radius) || 120,
  strength: Number(el.dataset.strength) || 50,
  // 基準位置は最初に1度だけ測っておく
  rect: el.getBoundingClientRect(),
}));

// リサイズやスクロールで基準がずれるので測り直す
function measure() {
  for (const c of circles) {
    c.el.style.transform = 'translate(0, 0)';
    c.rect = c.el.getBoundingClientRect();
  }
}
addEventListener('resize', measure);

function update() {
  ticking = false;

  for (const c of circles) {
    const centerX = c.rect.left + c.rect.width / 2;
    const centerY = c.rect.top + c.rect.height / 2;

    const dx = centerX - pointerX;
    const dy = centerY - pointerY;
    const distance = Math.hypot(dx, dy);

    if (distance > c.radius || distance === 0) {
      c.el.style.transform = 'translate(0, 0)';
      continue;
    }

    const ratio = (c.radius - distance) / c.radius;
    const moveX = (dx / distance) * c.strength * ratio;
    const moveY = (dy / distance) * c.strength * ratio;

    c.el.style.transform = `translate(${moveX}px, ${moveY}px)`;
  }
}

 

ポイント

  • 基準位置を毎フレーム測らないgetBoundingClientRect() は変形後の位置を返すため、動いている要素に対して毎回呼ぶと基準がずれて挙動が不安定になります。最初に1度だけ測って保持します
  • リサイズ時は測り直す:測り直す前に transform をリセットしないと、ずれた位置を基準にしてしまいます
  • ループの中で querySelectorAll を呼ばない:配列にしておいて使い回します

 

応用②:Three.js のパーティクルと連携する

See the Pen
Repelling effect 03
by Rin (@rinblog0408)
on CodePen.

 

読み込み方が変わった点に注意してください。 かつて使われていた build/three.min.js は r150 で非推奨になり、r161 で削除されました。現在は ES モジュールとして読み込みます。

<!-- 昔の書き方。r161 以降には存在しないファイル -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

 

<!-- 現在の書き方:importmap で解決する -->
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js"
  }
}
</script>

<script type="module">
  import * as THREE from 'three';
  // ...
</script>

 

実装は次の通りです。マウス位置に応じてパーティクル群を回転させるのではなく、近くのパーティクルだけを押しのける形にすると、リペリングらしい動きになります。

import * as THREE from 'three';

const COUNT = 800;
const REPEL_RADIUS = 120;
const REPEL_STRENGTH = 60;

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 1, 1000);
camera.position.z = 500;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // 高解像度端末での負荷を抑える
document.body.appendChild(renderer.domElement);

// 元の座標を保持しておき、そこへ戻す
const base = new Float32Array(COUNT * 3);
const positions = new Float32Array(COUNT * 3);

for (let i = 0; i < COUNT * 3; i++) {
  base[i] = Math.random() * 800 - 400;
  positions[i] = base[i];
}

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

const material = new THREE.PointsMaterial({ color: 0xffffff, size: 4 });
const particles = new THREE.Points(geometry, material);
scene.add(particles);

// カーソル位置をワールド座標に合わせる
let mx = 0, my = 0;
addEventListener('pointermove', (e) => {
  mx = (e.clientX / innerWidth) * 800 - 400;
  my = -((e.clientY / innerHeight) * 800 - 400);
});

function animate() {
  for (let i = 0; i < COUNT; i++) {
    const ix = i * 3;
    const dx = base[ix] - mx;
    const dy = base[ix + 1] - my;
    const distance = Math.hypot(dx, dy);

    if (distance < REPEL_RADIUS && distance > 0) {
      const ratio = (REPEL_RADIUS - distance) / REPEL_RADIUS;
      // 目標位置=元の位置+反発分
      const tx = base[ix]     + (dx / distance) * REPEL_STRENGTH * ratio;
      const ty = base[ix + 1] + (dy / distance) * REPEL_STRENGTH * ratio;
      // 少しずつ近づけると滑らかになる
      positions[ix]     += (tx - positions[ix]) * 0.15;
      positions[ix + 1] += (ty - positions[ix + 1]) * 0.15;
    } else {
      // 元の位置へ戻す
      positions[ix]     += (base[ix]     - positions[ix]) * 0.08;
      positions[ix + 1] += (base[ix + 1] - positions[ix + 1]) * 0.08;
    }
  }

  geometry.attributes.position.needsUpdate = true;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

// 動きを減らす設定なら静止画として1回だけ描画する
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
  renderer.render(scene, camera);
} else {
  animate();
}

 

ポイント

  • 元の座標(base)を別に保持する:現在位置だけを更新していくと、少しずつずれて元の配置に戻れなくなります
  • needsUpdate = true を忘れない:これが無いと、配列を書き換えても画面が変わりません
  • setPixelRatio は2で頭打ちにする:3倍以上の端末でそのまま描画すると、描画面積が9倍になって処理が重くなります
  • 補間(* 0.15)で滑らかにする:目標位置へ一気に飛ばさず、毎フレーム少しずつ近づけます

 

忘れてはいけない配慮

prefers-reduced-motion

カーソル追従の動きは、視差効果に敏感なユーザーにとって特に負担が大きい種類の演出です。 必ず判定して、無効化してください。

const mq = matchMedia('(prefers-reduced-motion: reduce)');

if (!mq.matches) {
  startRepelEffect();
}

// 設定が途中で変わった場合にも対応する
mq.addEventListener('change', () => {
  if (mq.matches) stopRepelEffect();
  else startRepelEffect();
});

 

タッチデバイスでは動かさない

タッチ操作にはホバーの概念がありません。pointermove は指を滑らせたときにも発火しますが、スクロール中に要素が逃げ回るのは邪魔になるだけです。

// ホバーできる環境でだけ有効にする
if (matchMedia('(hover: hover) and (pointer: fine)').matches) {
  startRepelEffect();
}

 

操作対象には使わない

ボタン、リンク、フォーム部品には絶対に適用しないでください。 「押そうとすると逃げる」UIは、運動機能に制約のあるユーザーにとって操作不能になります。装飾用の図形や背景のパーティクルに限定してください。

 

まとめ

  • 反発の向きは「要素の中心 − カーソル」。 逆にすると吸い寄せられる
  • 角度計算より単位ベクトル(dx / distanceの方が短く速い
  • distance === 0 を除外しないと NaN になる
  • イベントでは座標保存だけ、計算は requestAnimationFrame で1フレーム1回
  • 基準位置は最初に1度測って保持する。毎フレーム getBoundingClientRect() を呼ばない
  • three.min.js は r161 で削除済み。 importmap + ESモジュールで読み込む
  • Three.js では元の座標を別配列に持ち、needsUpdate = true を忘れない
  • prefers-reduced-motion(hover: hover) で無効化する。操作対象には使わない

アニメーションの制御全般についてはWeb Animations APIのanimateメソッド、スクロール連動の演出はCSSのスクロール駆動アニメーションにまとめています。

ABOUT ME
りん
このブログでは、Web開発やプログラミングに関する情報を中心に、私が日々感じたことや学んだことをシェアしています。技術と生活の両方を楽しめるブログを目指して、日常で触れた出来事や本、グルメの話題も取り入れています。気軽に覗いて、少しでも役立つ情報や楽しいひとときを見つけてもらえたら嬉しいです。