磁力グリッド

ドットの格子がカーソルの磁場で引き寄せ・反発し、近づくほど大きく波打って歪みます。待機中は仮想カーソルが巡回して常に動き、pointermoveで本物に追従。ヒーロー背景やインタラクティブな装飾に使えます。

#interaction#magnetic#grid#cursor

ライブデモ

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

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

HTML
<!-- MOON BREW: ブランドヒーロー。背景に磁力グリッドを主役で敷く -->
<div class="mb">
  <canvas class="mg-canvas" aria-hidden="true"></canvas>
  <div class="mb__overlay">
    <header class="mb__nav">
      <span class="mb__logo">☕ MOON BREW</span>
      <nav class="mb__menu">
        <span>MENU</span><span>STORE</span><span>ABOUT</span>
      </nav>
    </header>

    <div class="mb__center">
      <p class="mb__eyebrow">SINCE 2014 ・ SHIBUYA</p>
      <h1 class="mb__title">月あかりの、<br>一杯を。</h1>
      <p class="mb__lead">深夜まで灯る、自家焙煎のスペシャルティコーヒー。</p>
      <span class="mb__btn">店舗を探す →</span>
    </div>
    <p class="mb__hint">背景にカーソルを動かすとドットが波打ちます</p>
  </div>
</div>
CSS
/* MOON BREW カフェ テーマ: 濃ブラウン背景に琥珀グリッド */
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Hiragino Mincho ProN", "Segoe UI", system-ui, serif;
  color: #f5ede1;
  overflow: hidden;
}

.mb {
  position: relative;
  height: 400px;
  background:
    radial-gradient(circle at 30% 20%, #3a2718 0%, transparent 55%),
    #211309;
}

/* 主役: 磁力グリッドのcanvas(背景全面) */
.mg-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.mb__overlay {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 20px 30px 14px;
  pointer-events: none; /* グリッドへポインタを通す */
}

.mb__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.mb__logo {
  font-size: 18px;
  font-weight: 700;
  letter-spacing: .05em;
}
.mb__menu {
  display: flex;
  gap: 22px;
  font-size: 12px;
  letter-spacing: .12em;
  color: rgba(245,237,225,.7);
  font-family: "Segoe UI", system-ui, sans-serif;
}

.mb__center {
  flex: 1;
  display: grid;
  align-content: center;
  gap: 14px;
}
.mb__eyebrow {
  margin: 0;
  font-size: 12px;
  letter-spacing: .22em;
  color: #c98a3b;
  font-family: "Segoe UI", system-ui, sans-serif;
}
.mb__title {
  margin: 0;
  font-size: 40px;
  font-weight: 700;
  line-height: 1.28;
  text-shadow: 0 6px 24px rgba(0,0,0,.5);
}
.mb__lead {
  margin: 0;
  font-size: 13px;
  color: rgba(245,237,225,.72);
  font-family: "Segoe UI", system-ui, sans-serif;
}
.mb__btn {
  justify-self: start;
  margin-top: 6px;
  font-family: "Segoe UI", system-ui, sans-serif;
  font-size: 13px;
  font-weight: 700;
  color: #211309;
  background: linear-gradient(135deg, #d9b073, #c98a3b);
  padding: 11px 24px;
  border-radius: 999px;
  box-shadow: 0 10px 26px rgba(201,138,59,.4);
}

.mb__hint {
  margin: 0;
  font-size: 11px;
  color: rgba(245,237,225,.4);
  font-family: "Segoe UI", system-ui, sans-serif;
}
JavaScript
// MOON BREW背景: ドット格子をカーソルの磁場で引き寄せて波打たせる
(() => {
  const canvas = document.querySelector('.mg-canvas');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

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

  // 設定値
  const GAP = 32;        // 格子間隔(px)
  const RADIUS = 120;    // 磁場の有効半径(px)
  const PULL = 24;       // 最大変位量(px)
  const EASE = 0.14;     // 目標へ寄る滑らかさ
  const COLORS = ['#7a5224', '#c98a3b', '#f0c889']; // 琥珀グラデーション

  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  let dots = [];
  let W = 0, H = 0;
  let rafId = null;

  // 待機中は仮想カーソル、pointermoveで本物に切替
  const pointer = { x: 0, y: 0 };
  let useReal = false;
  let lastMove = 0;
  const IDLE_MS = 1800;

  // 格子を再構築(リサイズ対応)
  const build = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = canvas.clientWidth || 1;
    H = canvas.clientHeight || 1;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    dots = [];
    const cols = Math.max(1, Math.floor((W - GAP) / GAP) + 1);
    const rows = Math.max(1, Math.floor((H - GAP) / GAP) + 1);
    const offX = (W - (cols - 1) * GAP) / 2;
    const offY = (H - (rows - 1) * GAP) / 2;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const bx = offX + c * GAP;
        const by = offY + r * GAP;
        dots.push({ bx, by, x: bx, y: by });
      }
    }
    if (!useReal) { pointer.x = W / 2; pointer.y = H / 2; }
  };

  // 仮想カーソルの巡回(リサジューでゆっくり8の字)
  const virtualPointer = (t) => {
    const cx = W / 2, cy = H / 2;
    pointer.x = cx + Math.sin(t * 0.0007) * W * 0.34;
    pointer.y = cy + Math.sin(t * 0.0011 + 0.6) * H * 0.30;
  };

  // 距離に応じた色を線形補間で作る
  const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
  const C0 = hex(COLORS[0]), C1 = hex(COLORS[1]), C2 = hex(COLORS[2]);
  const mixColor = (t) => {
    let a, b, k;
    if (t < 0.5) { a = C0; b = C1; k = t * 2; }
    else { a = C1; b = C2; k = (t - 0.5) * 2; }
    const r = (a[0] + (b[0] - a[0]) * k) | 0;
    const g = (a[1] + (b[1] - a[1]) * k) | 0;
    const bl = (a[2] + (b[2] - a[2]) * k) | 0;
    return `rgb(${r},${g},${bl})`;
  };

  // 1フレーム更新+描画
  const loop = (t) => {
    if (useReal && t - lastMove > IDLE_MS) useReal = false;
    if (!useReal && !reduce) virtualPointer(t);

    ctx.clearRect(0, 0, W, H);
    const r2 = RADIUS * RADIUS;

    for (let i = 0; i < dots.length; i++) {
      const d = dots[i];
      const dx = d.bx - pointer.x;
      const dy = d.by - pointer.y;
      const dist2 = dx * dx + dy * dy;

      let tx = d.bx, ty = d.by;
      let influence = 0;
      if (dist2 < r2) {
        const dist = Math.sqrt(dist2) || 0.0001;
        influence = 1 - dist / RADIUS;       // 近いほど強い
        const force = influence * influence; // 減衰
        const ux = -dx / dist, uy = -dy / dist;
        tx = d.bx + ux * PULL * force;
        ty = d.by + uy * PULL * force;
      }

      // イージングで波打ちの余韻を残す
      d.x += (tx - d.x) * EASE;
      d.y += (ty - d.y) * EASE;

      const size = 1.4 + influence * 3;
      ctx.globalAlpha = 0.3 + influence * 0.6;
      ctx.fillStyle = influence > 0.001 ? mixColor(influence) : 'rgba(160,120,70,0.8)';
      ctx.beginPath();
      ctx.arc(d.x, d.y, size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;
    rafId = requestAnimationFrame(loop);
  };

  // 本物のポインタに追従
  const onMove = (e) => {
    const rect = canvas.getBoundingClientRect();
    pointer.x = e.clientX - rect.left;
    pointer.y = e.clientY - rect.top;
    useReal = true;
    lastMove = performance.now();
  };

  const stage = canvas.parentElement || canvas;
  stage.addEventListener('pointermove', onMove, { passive: true });

  // リサイズで格子を作り直す
  let resizeTimer = null;
  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(build, 120);
  });

  build();
  if (reduce) {
    // 抑制時は静止格子を1枚だけ描く
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = 'rgba(160,120,70,0.8)';
    for (const d of dots) {
      ctx.beginPath();
      ctx.arc(d.bx, d.by, 1.6, 0, Math.PI * 2);
      ctx.fill();
    }
  } else {
    rafId = requestAnimationFrame(loop);
  }

  // タブ非表示で停止・再表示で再開
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
    } else if (!reduce && !rafId) {
      rafId = requestAnimationFrame(loop);
    }
  });
})();

コード

HTML
<!-- 磁力グリッド: ドット格子がカーソルの磁場で引き寄せ・反発して波打つ -->
<div class="mg-stage">
  <canvas class="mg-canvas" aria-hidden="true"></canvas>
  <p class="mg-hint">カーソルを動かすと格子が波打ちます</p>
</div>
CSS
/* ステージ全体 */
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 25% 15%, #1b2a6b 0%, transparent 50%),
    radial-gradient(circle at 80% 85%, #5e1b6e 0%, transparent 50%),
    #070912;
  overflow: hidden;
}

/* ドット格子を敷くステージ */
.mg-stage {
  position: relative;
  width: 100%;
  min-height: 100vh;
  cursor: crosshair;
}

/* 格子描画用キャンバスは全面に敷く */
.mg-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* 操作ヒント */
.mg-hint {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  margin: 0;
  padding: 0 14px;
  font-size: 12px;
  letter-spacing: .05em;
  color: rgba(255, 255, 255, .45);
  pointer-events: none;
  user-select: none;
}
JavaScript
// 磁力グリッド: ドット格子をカーソルの磁場で引き寄せ・反発させて波打たせる
(() => {
  const canvas = document.querySelector('.mg-canvas');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

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

  // 設定値
  const GAP = 34;        // 格子間隔(px)
  const RADIUS = 130;    // 磁場の有効半径(px)
  const PULL = 26;       // 最大変位量(px) ※正で引き寄せ
  const EASE = 0.14;     // ドットが目標位置へ寄る滑らかさ
  const COLORS = ['#7ce8ff', '#9b7cff', '#ff7ce0']; // 距離で色補間

  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  let dots = [];        // 各ドットの基準/現在位置
  let W = 0, H = 0;     // CSSピクセル幅・高さ
  let rafId = null;

  // ポインタ状態: 待機中は仮想カーソル、pointermoveで本物に切替
  const pointer = { x: 0, y: 0 };
  let useReal = false;  // 一度でも動いたら本物追従
  let lastMove = 0;     // 最後の本物入力時刻
  const IDLE_MS = 1800; // 無操作が続いたら仮想カーソルへ復帰

  // 格子を再構築(リサイズ対応)
  const build = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = canvas.clientWidth || 1;
    H = canvas.clientHeight || 1;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSSピクセル基準で描画

    dots = [];
    // 端に余白が出ないよう中央寄せでマージンを計算
    const cols = Math.max(1, Math.floor((W - GAP) / GAP) + 1);
    const rows = Math.max(1, Math.floor((H - GAP) / GAP) + 1);
    const offX = (W - (cols - 1) * GAP) / 2;
    const offY = (H - (rows - 1) * GAP) / 2;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const bx = offX + c * GAP;
        const by = offY + r * GAP;
        dots.push({ bx, by, x: bx, y: by });
      }
    }
    // 仮想カーソル初期位置を中央に
    if (!useReal) { pointer.x = W / 2; pointer.y = H / 2; }
  };

  // 仮想カーソルの巡回位置(リサジュー曲線でゆっくり8の字)
  const virtualPointer = (t) => {
    const cx = W / 2, cy = H / 2;
    const ax = W * 0.34, ay = H * 0.30;
    pointer.x = cx + Math.sin(t * 0.0007) * ax;
    pointer.y = cy + Math.sin(t * 0.0011 + 0.6) * ay;
  };

  // 16進カラーを線形補間して距離に応じた色を作る
  const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
  const C0 = hex(COLORS[0]), C1 = hex(COLORS[1]), C2 = hex(COLORS[2]);
  const mixColor = (t) => {
    // t:0→1 を 3色グラデーションへ写像
    let a, b, k;
    if (t < 0.5) { a = C0; b = C1; k = t * 2; }
    else { a = C1; b = C2; k = (t - 0.5) * 2; }
    const r = (a[0] + (b[0] - a[0]) * k) | 0;
    const g = (a[1] + (b[1] - a[1]) * k) | 0;
    const bl = (a[2] + (b[2] - a[2]) * k) | 0;
    return `rgb(${r},${g},${bl})`;
  };

  // 1フレーム更新+描画
  const loop = (t) => {
    // 本物入力が途切れたら仮想カーソルへ復帰
    if (useReal && t - lastMove > IDLE_MS) useReal = false;
    if (!useReal && !reduce) virtualPointer(t);

    ctx.clearRect(0, 0, W, H);
    const r2 = RADIUS * RADIUS;

    for (let i = 0; i < dots.length; i++) {
      const d = dots[i];
      const dx = d.bx - pointer.x;
      const dy = d.by - pointer.y;
      const dist2 = dx * dx + dy * dy;

      let tx = d.bx, ty = d.by;       // 目標位置(既定は基準位置)
      let influence = 0;              // 磁場の効き具合 0〜1
      if (dist2 < r2) {
        const dist = Math.sqrt(dist2) || 0.0001;
        influence = 1 - dist / RADIUS;        // 近いほど強い
        const force = influence * influence;  // 減衰を効かせる
        // カーソル方向へ引き寄せる(基準位置→カーソルへ変位)
        const ux = -dx / dist, uy = -dy / dist;
        tx = d.bx + ux * PULL * force;
        ty = d.by + uy * PULL * force;
      }

      // イージングで目標位置へ滑らかに追従(波打ちの余韻)
      d.x += (tx - d.x) * EASE;
      d.y += (ty - d.y) * EASE;

      // 影響に応じてサイズ・明るさ・色を変化
      const size = 1.4 + influence * 3.2;
      ctx.globalAlpha = 0.35 + influence * 0.65;
      ctx.fillStyle = influence > 0.001 ? mixColor(influence) : 'rgba(150,170,255,0.9)';
      ctx.beginPath();
      ctx.arc(d.x, d.y, size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;

    rafId = requestAnimationFrame(loop);
  };

  // 本物のポインタに追従
  const onMove = (e) => {
    const rect = canvas.getBoundingClientRect();
    pointer.x = e.clientX - rect.left;
    pointer.y = e.clientY - rect.top;
    useReal = true;
    lastMove = performance.now();
  };

  const stage = canvas.parentElement || canvas;
  stage.addEventListener('pointermove', onMove, { passive: true });

  // リサイズで格子を作り直す
  let resizeTimer = null;
  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(build, 120);
  });

  build();
  if (reduce) {
    // モーション抑制時は静止した格子を1枚だけ描く
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = 'rgba(150,170,255,0.9)';
    for (const d of dots) {
      ctx.beginPath();
      ctx.arc(d.bx, d.by, 1.6, 0, Math.PI * 2);
      ctx.fill();
    }
  } else {
    rafId = requestAnimationFrame(loop);
  }

  // タブ非表示で停止し、再表示で再開(無駄なRAFを避ける)
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
    } else if (!reduce && !rafId) {
      rafId = requestAnimationFrame(loop);
    }
  });
})();

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

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

# 追加してほしい効果
磁力グリッド(マイクロインタラクション)
ドットの格子がカーソルの磁場で引き寄せ・反発し、近づくほど大きく波打って歪みます。待機中は仮想カーソルが巡回して常に動き、pointermoveで本物に追従。ヒーロー背景やインタラクティブな装飾に使えます。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 磁力グリッド: ドット格子がカーソルの磁場で引き寄せ・反発して波打つ -->
<div class="mg-stage">
  <canvas class="mg-canvas" aria-hidden="true"></canvas>
  <p class="mg-hint">カーソルを動かすと格子が波打ちます</p>
</div>

【CSS】
/* ステージ全体 */
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 25% 15%, #1b2a6b 0%, transparent 50%),
    radial-gradient(circle at 80% 85%, #5e1b6e 0%, transparent 50%),
    #070912;
  overflow: hidden;
}

/* ドット格子を敷くステージ */
.mg-stage {
  position: relative;
  width: 100%;
  min-height: 100vh;
  cursor: crosshair;
}

/* 格子描画用キャンバスは全面に敷く */
.mg-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* 操作ヒント */
.mg-hint {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  margin: 0;
  padding: 0 14px;
  font-size: 12px;
  letter-spacing: .05em;
  color: rgba(255, 255, 255, .45);
  pointer-events: none;
  user-select: none;
}

【JavaScript】
// 磁力グリッド: ドット格子をカーソルの磁場で引き寄せ・反発させて波打たせる
(() => {
  const canvas = document.querySelector('.mg-canvas');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

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

  // 設定値
  const GAP = 34;        // 格子間隔(px)
  const RADIUS = 130;    // 磁場の有効半径(px)
  const PULL = 26;       // 最大変位量(px) ※正で引き寄せ
  const EASE = 0.14;     // ドットが目標位置へ寄る滑らかさ
  const COLORS = ['#7ce8ff', '#9b7cff', '#ff7ce0']; // 距離で色補間

  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  let dots = [];        // 各ドットの基準/現在位置
  let W = 0, H = 0;     // CSSピクセル幅・高さ
  let rafId = null;

  // ポインタ状態: 待機中は仮想カーソル、pointermoveで本物に切替
  const pointer = { x: 0, y: 0 };
  let useReal = false;  // 一度でも動いたら本物追従
  let lastMove = 0;     // 最後の本物入力時刻
  const IDLE_MS = 1800; // 無操作が続いたら仮想カーソルへ復帰

  // 格子を再構築(リサイズ対応)
  const build = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = canvas.clientWidth || 1;
    H = canvas.clientHeight || 1;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSSピクセル基準で描画

    dots = [];
    // 端に余白が出ないよう中央寄せでマージンを計算
    const cols = Math.max(1, Math.floor((W - GAP) / GAP) + 1);
    const rows = Math.max(1, Math.floor((H - GAP) / GAP) + 1);
    const offX = (W - (cols - 1) * GAP) / 2;
    const offY = (H - (rows - 1) * GAP) / 2;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const bx = offX + c * GAP;
        const by = offY + r * GAP;
        dots.push({ bx, by, x: bx, y: by });
      }
    }
    // 仮想カーソル初期位置を中央に
    if (!useReal) { pointer.x = W / 2; pointer.y = H / 2; }
  };

  // 仮想カーソルの巡回位置(リサジュー曲線でゆっくり8の字)
  const virtualPointer = (t) => {
    const cx = W / 2, cy = H / 2;
    const ax = W * 0.34, ay = H * 0.30;
    pointer.x = cx + Math.sin(t * 0.0007) * ax;
    pointer.y = cy + Math.sin(t * 0.0011 + 0.6) * ay;
  };

  // 16進カラーを線形補間して距離に応じた色を作る
  const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
  const C0 = hex(COLORS[0]), C1 = hex(COLORS[1]), C2 = hex(COLORS[2]);
  const mixColor = (t) => {
    // t:0→1 を 3色グラデーションへ写像
    let a, b, k;
    if (t < 0.5) { a = C0; b = C1; k = t * 2; }
    else { a = C1; b = C2; k = (t - 0.5) * 2; }
    const r = (a[0] + (b[0] - a[0]) * k) | 0;
    const g = (a[1] + (b[1] - a[1]) * k) | 0;
    const bl = (a[2] + (b[2] - a[2]) * k) | 0;
    return `rgb(${r},${g},${bl})`;
  };

  // 1フレーム更新+描画
  const loop = (t) => {
    // 本物入力が途切れたら仮想カーソルへ復帰
    if (useReal && t - lastMove > IDLE_MS) useReal = false;
    if (!useReal && !reduce) virtualPointer(t);

    ctx.clearRect(0, 0, W, H);
    const r2 = RADIUS * RADIUS;

    for (let i = 0; i < dots.length; i++) {
      const d = dots[i];
      const dx = d.bx - pointer.x;
      const dy = d.by - pointer.y;
      const dist2 = dx * dx + dy * dy;

      let tx = d.bx, ty = d.by;       // 目標位置(既定は基準位置)
      let influence = 0;              // 磁場の効き具合 0〜1
      if (dist2 < r2) {
        const dist = Math.sqrt(dist2) || 0.0001;
        influence = 1 - dist / RADIUS;        // 近いほど強い
        const force = influence * influence;  // 減衰を効かせる
        // カーソル方向へ引き寄せる(基準位置→カーソルへ変位)
        const ux = -dx / dist, uy = -dy / dist;
        tx = d.bx + ux * PULL * force;
        ty = d.by + uy * PULL * force;
      }

      // イージングで目標位置へ滑らかに追従(波打ちの余韻)
      d.x += (tx - d.x) * EASE;
      d.y += (ty - d.y) * EASE;

      // 影響に応じてサイズ・明るさ・色を変化
      const size = 1.4 + influence * 3.2;
      ctx.globalAlpha = 0.35 + influence * 0.65;
      ctx.fillStyle = influence > 0.001 ? mixColor(influence) : 'rgba(150,170,255,0.9)';
      ctx.beginPath();
      ctx.arc(d.x, d.y, size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;

    rafId = requestAnimationFrame(loop);
  };

  // 本物のポインタに追従
  const onMove = (e) => {
    const rect = canvas.getBoundingClientRect();
    pointer.x = e.clientX - rect.left;
    pointer.y = e.clientY - rect.top;
    useReal = true;
    lastMove = performance.now();
  };

  const stage = canvas.parentElement || canvas;
  stage.addEventListener('pointermove', onMove, { passive: true });

  // リサイズで格子を作り直す
  let resizeTimer = null;
  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(build, 120);
  });

  build();
  if (reduce) {
    // モーション抑制時は静止した格子を1枚だけ描く
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = 'rgba(150,170,255,0.9)';
    for (const d of dots) {
      ctx.beginPath();
      ctx.arc(d.bx, d.by, 1.6, 0, Math.PI * 2);
      ctx.fill();
    }
  } else {
    rafId = requestAnimationFrame(loop);
  }

  // タブ非表示で停止し、再表示で再開(無駄なRAFを避ける)
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
    } else if (!reduce && !rafId) {
      rafId = requestAnimationFrame(loop);
    }
  });
})();

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

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