重力で集まる粒子フィールド

マウスへの引力で粒子が集まり、クリックや右クリックで弾け飛ぶインタラクティブな重力シミュレーション。遊び心のある背景やヒーローに。

#canvas#particles#physics#interactive

ライブデモ

使用例(お題: SaaS FlowDesk)

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

HTML
<!-- FlowDesk:データ統合を表す重力フィールドのヒーロー -->
<section class="fd-grav">
  <!-- 主役:マウスに集まる重力パーティクル -->
  <canvas class="fd-grav__fx" id="fdOrbit"></canvas>

  <!-- 前景UI -->
  <div class="fd-grav__inner">
    <span class="fd-grav__tag">DATA INTEGRATION</span>
    <h1 class="fd-grav__title">散らばる情報を、<br>ひとつに引き寄せる。</h1>
    <p class="fd-grav__lead">200以上の連携先からデータを自動収集。カーソルを動かすと粒子が集まり、クリックで弾けます。</p>
    <div class="fd-grav__actions">
      <a class="fd-btn fd-btn--main" href="#">連携を試す</a>
      <span class="fd-grav__hint">クリック=発散 / 右クリック=吸引</span>
    </div>
  </div>
</section>
CSS
/* FlowDesk:重力パーティクルのヒーロー */
:root {
  --navy: #0f1b34;
  --blue: #4f7cff;
  --white: #ffffff;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif;
  overflow: hidden;
}

.fd-grav {
  position: relative;
  height: 400px;
  overflow: hidden;
  background:
    radial-gradient(800px 400px at 80% 50%, #18294e, transparent),
    linear-gradient(160deg, #0f1b34, #0a1226);
}

/* 主役:重力キャンバス(全面) */
.fd-grav__fx {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 1;
}

.fd-grav__inner {
  position: relative;
  z-index: 2;
  padding: 40px 30px;
  max-width: 420px;
  pointer-events: none;
}
.fd-grav__inner a { pointer-events: auto; }

.fd-grav__tag {
  display: inline-block;
  font-size: 10px;
  letter-spacing: 0.28em;
  color: #8fb0ff;
  font-weight: 800;
}
.fd-grav__title {
  margin: 13px 0 13px;
  font-size: 29px;
  line-height: 1.38;
  font-weight: 800;
  color: var(--white);
}
.fd-grav__lead {
  margin: 0 0 22px;
  font-size: 13px;
  line-height: 1.8;
  color: rgba(255,255,255,0.78);
  max-width: 340px;
}
.fd-grav__actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.fd-btn {
  display: inline-block;
  padding: 11px 22px;
  border-radius: 9px;
  font-size: 13px;
  font-weight: 700;
  text-decoration: none;
  transition: transform 0.2s ease;
}
.fd-btn--main {
  background: var(--blue);
  color: var(--white);
  box-shadow: 0 10px 24px rgba(79,124,255,0.45);
}
.fd-btn:hover { transform: translateY(-2px); }
.fd-grav__hint { font-size: 11px; color: rgba(255,255,255,0.5); }

@media (prefers-reduced-motion: reduce) {
  .fd-btn { transition: none; }
}
JavaScript
// FlowDesk:マウスへの引力で集まる粒子フィールド
(() => {
  const canvas = document.getElementById('fdOrbit');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  let w = 0, h = 0, raf = 0, running = true, particles = [];
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  // 引力(1)/斥力(-1)、未操作時は弱い引力
  const mouse = { x: -9999, y: -9999, sign: 1, active: false };

  function resize() {
    const r = canvas.getBoundingClientRect();
    w = r.width; h = r.height;
    canvas.width = Math.max(1, w * dpr);
    canvas.height = Math.max(1, h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function makeParticles() {
    const count = Math.max(60, Math.min(160, Math.floor((w * h) / 6500)));
    particles = Array.from({ length: count }, () => ({
      x: Math.random() * w,
      y: Math.random() * h,
      vx: (Math.random() - 0.5) * 0.6,
      vy: (Math.random() - 0.5) * 0.6,
      r: Math.random() * 1.6 + 0.8
    }));
  }

  resize();
  makeParticles();

  function step() {
    ctx.fillStyle = 'rgba(10,18,38,0.25)'; // 軌跡を少し残す
    ctx.fillRect(0, 0, w, h);

    const cx = mouse.active ? mouse.x : w / 2;
    const cy = mouse.active ? mouse.y : h / 2;
    const strength = mouse.active ? 0.9 : 0.18;

    for (const p of particles) {
      const dx = cx - p.x;
      const dy = cy - p.y;
      const d2 = dx * dx + dy * dy + 400; // 0除算回避
      const f = (mouse.sign * strength) / d2 * 60;
      p.vx += dx * f;
      p.vy += dy * f;
      p.vx *= 0.96; // 減衰
      p.vy *= 0.96;
      p.x += p.vx;
      p.y += p.vy;

      // 画面外で反対側に
      if (p.x < -10) p.x = w + 10;
      if (p.x > w + 10) p.x = -10;
      if (p.y < -10) p.y = h + 10;
      if (p.y > h + 10) p.y = -10;

      // 速度で色味を変える(青〜水色)
      const sp = Math.min(1, Math.hypot(p.vx, p.vy) / 4);
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(${120 + sp * 100},${150 + sp * 60},255,0.9)`;
      ctx.fill();
    }
    raf = requestAnimationFrame(step);
  }

  function start() {
    if (running) return;
    running = true;
    raf = requestAnimationFrame(step);
  }
  function stop() {
    running = false;
    cancelAnimationFrame(raf);
  }

  function setMouse(e) {
    const r = canvas.getBoundingClientRect();
    mouse.x = e.clientX - r.left;
    mouse.y = e.clientY - r.top;
    mouse.active = true;
  }
  canvas.addEventListener('pointermove', setMouse);
  canvas.addEventListener('pointerleave', () => { mouse.active = false; mouse.sign = 1; });
  // 左クリックで一瞬だけ斥力(弾け飛ぶ)
  canvas.addEventListener('pointerdown', (e) => {
    setMouse(e);
    mouse.sign = e.button === 2 ? 1 : -1;
  });
  canvas.addEventListener('pointerup', () => { mouse.sign = 1; });
  // 右クリックメニュー抑止(右クリック=吸引の演出用)
  canvas.addEventListener('contextmenu', (e) => e.preventDefault());

  window.addEventListener('resize', () => { resize(); makeParticles(); });
  document.addEventListener('visibilitychange', () => {
    document.hidden ? stop() : start();
  });

  running = false;
  start();
})();

コード

HTML
<!-- 重力に集まる粒子(マウスで引き寄せ/クリックで反発) -->
<div class="stage">
  <canvas id="orbitCanvas"></canvas>
  <div class="tip">ドラッグで引き寄せ・クリックで反発</div>
</div>
CSS
/* 重力粒子フィールド */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
  position: relative;
  width: 100%;
  height: 360px;
  overflow: hidden;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(800px 500px at 50% 50%, #161032, #07040f 75%);
  cursor: crosshair;
}
#orbitCanvas { display: block; width: 100%; height: 100%; }
.tip {
  position: absolute;
  left: 50%; bottom: 14px;
  transform: translateX(-50%);
  color: rgba(255, 255, 255, .35);
  font-size: 12px;
  letter-spacing: .12em;
  pointer-events: none;
}
JavaScript
// 重力粒子デモ(マウスへ引力、クリックで反発)
(() => {
  const canvas = document.getElementById('orbitCanvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');

  let w = 0, h = 0;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const pointer = { x: 0, y: 0, active: false, repel: false };
  let particles = [];

  function resize() {
    const r = canvas.getBoundingClientRect();
    w = r.width; h = r.height;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function makeParticles() {
    const count = Math.max(80, Math.min(220, Math.floor((w * h) / 4500)));
    particles = Array.from({ length: count }, () => ({
      x: Math.random() * w,
      y: Math.random() * h,
      vx: 0, vy: 0,
      hue: 200 + Math.random() * 120
    }));
  }
  resize();
  makeParticles();
  pointer.x = w / 2; pointer.y = h / 2;
  window.addEventListener('resize', () => { resize(); makeParticles(); });

  function step() {
    // 残像で軌跡を描く
    ctx.fillStyle = 'rgba(7,4,15,0.2)';
    ctx.fillRect(0, 0, w, h);
    ctx.globalCompositeOperation = 'lighter';

    for (const p of particles) {
      const dx = pointer.x - p.x;
      const dy = pointer.y - p.y;
      const dist = Math.hypot(dx, dy) + 0.01;
      // マウスが押されている間だけ強い力、通常は弱い引力で漂う
      const base = pointer.active ? (pointer.repel ? -1.4 : 0.9) : 0.04;
      const force = base / dist;
      p.vx += dx * force;
      p.vy += dy * force;
      if (!pointer.active) {
        // 待機中は接線方向の力を加えて中心に潰れず周回させる
        p.vx += -dy / dist * 0.05;
        p.vy += dx / dist * 0.05;
      }
      p.vx *= 0.94; // 減衰
      p.vy *= 0.94;
      p.x += p.vx;
      p.y += p.vy;

      // 端でゆるく折り返す
      if (p.x < 0 || p.x > w) p.vx *= -0.6;
      if (p.y < 0 || p.y > h) p.vy *= -0.6;

      const speed = Math.hypot(p.vx, p.vy);
      ctx.beginPath();
      ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
      ctx.fillStyle = `hsla(${p.hue},90%,${50 + Math.min(40, speed * 6)}%,0.9)`;
      ctx.fill();
    }
    ctx.globalCompositeOperation = 'source-over';
    requestAnimationFrame(step);
  }

  function setPointer(e) {
    const r = canvas.getBoundingClientRect();
    pointer.x = e.clientX - r.left;
    pointer.y = e.clientY - r.top;
  }
  canvas.addEventListener('pointermove', (e) => { setPointer(e); });
  canvas.addEventListener('pointerdown', (e) => {
    setPointer(e);
    pointer.active = true;
    pointer.repel = (e.button === 2) || e.shiftKey; // 右クリック/Shiftで反発
  });
  window.addEventListener('pointerup', () => { pointer.active = false; });
  // クリックは一瞬反発させる(左クリックでもパッと弾ける演出)
  canvas.addEventListener('click', (e) => {
    setPointer(e);
    pointer.active = true; pointer.repel = true;
    setTimeout(() => { pointer.active = false; pointer.repel = false; }, 180);
  });
  canvas.addEventListener('contextmenu', (e) => e.preventDefault());

  requestAnimationFrame(step);
})();

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

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

# 追加してほしい効果
重力で集まる粒子フィールド(Canvas エフェクト)
マウスへの引力で粒子が集まり、クリックや右クリックで弾け飛ぶインタラクティブな重力シミュレーション。遊び心のある背景やヒーローに。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 重力に集まる粒子(マウスで引き寄せ/クリックで反発) -->
<div class="stage">
  <canvas id="orbitCanvas"></canvas>
  <div class="tip">ドラッグで引き寄せ・クリックで反発</div>
</div>

【CSS】
/* 重力粒子フィールド */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
  position: relative;
  width: 100%;
  height: 360px;
  overflow: hidden;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(800px 500px at 50% 50%, #161032, #07040f 75%);
  cursor: crosshair;
}
#orbitCanvas { display: block; width: 100%; height: 100%; }
.tip {
  position: absolute;
  left: 50%; bottom: 14px;
  transform: translateX(-50%);
  color: rgba(255, 255, 255, .35);
  font-size: 12px;
  letter-spacing: .12em;
  pointer-events: none;
}

【JavaScript】
// 重力粒子デモ(マウスへ引力、クリックで反発)
(() => {
  const canvas = document.getElementById('orbitCanvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');

  let w = 0, h = 0;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const pointer = { x: 0, y: 0, active: false, repel: false };
  let particles = [];

  function resize() {
    const r = canvas.getBoundingClientRect();
    w = r.width; h = r.height;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function makeParticles() {
    const count = Math.max(80, Math.min(220, Math.floor((w * h) / 4500)));
    particles = Array.from({ length: count }, () => ({
      x: Math.random() * w,
      y: Math.random() * h,
      vx: 0, vy: 0,
      hue: 200 + Math.random() * 120
    }));
  }
  resize();
  makeParticles();
  pointer.x = w / 2; pointer.y = h / 2;
  window.addEventListener('resize', () => { resize(); makeParticles(); });

  function step() {
    // 残像で軌跡を描く
    ctx.fillStyle = 'rgba(7,4,15,0.2)';
    ctx.fillRect(0, 0, w, h);
    ctx.globalCompositeOperation = 'lighter';

    for (const p of particles) {
      const dx = pointer.x - p.x;
      const dy = pointer.y - p.y;
      const dist = Math.hypot(dx, dy) + 0.01;
      // マウスが押されている間だけ強い力、通常は弱い引力で漂う
      const base = pointer.active ? (pointer.repel ? -1.4 : 0.9) : 0.04;
      const force = base / dist;
      p.vx += dx * force;
      p.vy += dy * force;
      if (!pointer.active) {
        // 待機中は接線方向の力を加えて中心に潰れず周回させる
        p.vx += -dy / dist * 0.05;
        p.vy += dx / dist * 0.05;
      }
      p.vx *= 0.94; // 減衰
      p.vy *= 0.94;
      p.x += p.vx;
      p.y += p.vy;

      // 端でゆるく折り返す
      if (p.x < 0 || p.x > w) p.vx *= -0.6;
      if (p.y < 0 || p.y > h) p.vy *= -0.6;

      const speed = Math.hypot(p.vx, p.vy);
      ctx.beginPath();
      ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
      ctx.fillStyle = `hsla(${p.hue},90%,${50 + Math.min(40, speed * 6)}%,0.9)`;
      ctx.fill();
    }
    ctx.globalCompositeOperation = 'source-over';
    requestAnimationFrame(step);
  }

  function setPointer(e) {
    const r = canvas.getBoundingClientRect();
    pointer.x = e.clientX - r.left;
    pointer.y = e.clientY - r.top;
  }
  canvas.addEventListener('pointermove', (e) => { setPointer(e); });
  canvas.addEventListener('pointerdown', (e) => {
    setPointer(e);
    pointer.active = true;
    pointer.repel = (e.button === 2) || e.shiftKey; // 右クリック/Shiftで反発
  });
  window.addEventListener('pointerup', () => { pointer.active = false; });
  // クリックは一瞬反発させる(左クリックでもパッと弾ける演出)
  canvas.addEventListener('click', (e) => {
    setPointer(e);
    pointer.active = true; pointer.repel = true;
    setTimeout(() => { pointer.active = false; pointer.repel = false; }, 180);
  });
  canvas.addEventListener('contextmenu', (e) => e.preventDefault());

  requestAnimationFrame(step);
})();

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

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