パーティクル文字

テキストのピクセルをサンプリングし、その座標へ無数の粒子が集まって文字を形成。マウスを近づけると弾け、離れると吸い寄せられて戻るインタラクティブ表現です。

#canvas#particles#text#interactive

ライブデモ

使用例(お題: SaaS FlowDesk)

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

HTML
<!-- FlowDesk:パーティクルで描くCTAブランド画面 -->
<section class="fd-cta">
  <!-- 主役:粒子が集まって文字を形成 -->
  <canvas class="fd-cta__fx" id="fdParticleText"></canvas>

  <!-- 前景UI -->
  <div class="fd-cta__inner">
    <p class="fd-cta__lead">今日から、チームの流れが変わる。</p>
    <div class="fd-cta__actions">
      <a class="fd-btn fd-btn--main" href="#">無料で始める</a>
      <a class="fd-btn fd-btn--ghost" href="#">資料ダウンロード</a>
    </div>
    <p class="fd-cta__note">マウスを文字に近づけると粒子が弾けます</p>
  </div>
</section>
CSS
/* FlowDesk:パーティクル文字のCTA */
: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-cta {
  position: relative;
  height: 400px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  background:
    radial-gradient(700px 360px at 50% 30%, #1a2b50, transparent),
    linear-gradient(160deg, #0f1b34, #0a1226);
}

/* 主役:粒子文字(上半分中心) */
.fd-cta__fx {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 1;
}

.fd-cta__inner {
  position: relative;
  z-index: 2;
  padding: 0 30px 36px;
  text-align: center;
  pointer-events: none;
}
.fd-cta__inner a { pointer-events: auto; }

.fd-cta__lead {
  margin: 0 0 18px;
  font-size: 14px;
  color: rgba(255,255,255,0.82);
  font-weight: 600;
}
.fd-cta__actions {
  display: flex;
  gap: 12px;
  justify-content: center;
  flex-wrap: wrap;
}
.fd-btn {
  display: inline-block;
  padding: 12px 24px;
  border-radius: 9px;
  font-size: 13.5px;
  font-weight: 700;
  text-decoration: none;
  transition: transform 0.2s ease;
}
.fd-btn--main {
  background: var(--blue);
  color: var(--white);
  box-shadow: 0 10px 26px rgba(79,124,255,0.5);
}
.fd-btn--ghost {
  background: rgba(255,255,255,0.08);
  color: var(--white);
  border: 1px solid rgba(255,255,255,0.2);
}
.fd-btn:hover { transform: translateY(-2px); }
.fd-cta__note {
  margin: 16px 0 0;
  font-size: 11px;
  color: rgba(255,255,255,0.45);
}

@media (prefers-reduced-motion: reduce) {
  .fd-btn { transition: none; }
}
JavaScript
// FlowDesk:テキストをサンプリングし粒子で文字を形成(マウスで弾ける)
(() => {
  const canvas = document.getElementById('fdParticleText');
  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);
  const mouse = { x: -9999, y: -9999 };
  const TEXT = 'FlowDesk';

  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 sampleTargets() {
    const off = document.createElement('canvas');
    off.width = Math.max(1, Math.floor(w));
    off.height = Math.max(1, Math.floor(h));
    const octx = off.getContext('2d');
    if (!octx) return [];
    const fontSize = Math.min(76, w / (TEXT.length * 0.62));
    octx.fillStyle = '#fff';
    octx.font = `800 ${fontSize}px "Segoe UI", system-ui, sans-serif`;
    octx.textAlign = 'center';
    octx.textBaseline = 'middle';
    octx.fillText(TEXT, off.width / 2, h * 0.4);

    const img = octx.getImageData(0, 0, off.width, off.height).data;
    const gap = 4; // サンプリング間隔
    const targets = [];
    for (let y = 0; y < off.height; y += gap) {
      for (let x = 0; x < off.width; x += gap) {
        if (img[(y * off.width + x) * 4 + 3] > 128) {
          targets.push({ x, y });
        }
      }
    }
    return targets;
  }

  // 目標点に対応する粒子を生成
  function buildParticles() {
    const targets = sampleTargets();
    particles = targets.map((t) => ({
      x: Math.random() * w,
      y: Math.random() * h,
      tx: t.x,
      ty: t.y,
      vx: 0,
      vy: 0
    }));
  }

  resize();
  buildParticles();

  function step() {
    ctx.clearRect(0, 0, w, h);

    for (const p of particles) {
      // 目標へのばね力
      let ax = (p.tx - p.x) * 0.02;
      let ay = (p.ty - p.y) * 0.02;

      // マウス反発
      const dx = p.x - mouse.x;
      const dy = p.y - mouse.y;
      const d2 = dx * dx + dy * dy;
      if (d2 < 1600) {
        const f = (1600 - d2) / 1600;
        const d = Math.sqrt(d2) || 1;
        ax += (dx / d) * f * 2.4;
        ay += (dy / d) * f * 2.4;
      }

      p.vx = (p.vx + ax) * 0.86; // 減衰
      p.vy = (p.vy + ay) * 0.86;
      p.x += p.vx;
      p.y += p.vy;

      ctx.fillStyle = 'rgba(120,160,255,0.95)';
      ctx.fillRect(p.x, p.y, 2, 2);
    }
    raf = requestAnimationFrame(step);
  }

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

  canvas.addEventListener('pointermove', (e) => {
    const r = canvas.getBoundingClientRect();
    mouse.x = e.clientX - r.left;
    mouse.y = e.clientY - r.top;
  });
  canvas.addEventListener('pointerleave', () => { mouse.x = mouse.y = -9999; });

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

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

コード

HTML
<!-- パーティクル文字デモ -->
<div class="stage">
  <canvas id="ptCanvas"></canvas>
  <div class="hint">マウスを近づけると粒子が散ります</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(900px 600px at 50% 45%, #0d1424, #05070d 85%);
  cursor: crosshair;
}
#ptCanvas {
  display: block;
  width: 100%;
  height: 100%;
}
/* 下部のヒント */
.hint {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  color: rgba(160, 200, 255, .5);
  font-size: 12px;
  letter-spacing: .06em;
  pointer-events: none;
  user-select: none;
}
JavaScript
// パーティクル文字デモ — テキストのピクセルを粒子で再現、マウスで散らす
(() => {
  const canvas = document.getElementById('ptCanvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  let w = 0, h = 0;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  let particles = [];
  let rafId = 0, running = false;
  const mouse = { x: -9999, y: -9999, active: false };
  const TEXT = 'HELLO';
  const REPEL = 46;               // マウス反発半径
  const GAP = 4;                  // ピクセルサンプリング間隔

  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);
    sampleTargets();
  }

  // オフスクリーンにテキストを描いてピクセルをサンプリング
  function sampleTargets() {
    const off = document.createElement('canvas');
    off.width = Math.max(1, w);
    off.height = Math.max(1, h);
    const octx = off.getContext('2d');
    if (!octx) return;
    octx.fillStyle = '#fff';
    octx.textAlign = 'center';
    octx.textBaseline = 'middle';
    // 横幅に収まるフォントサイズを決定
    let size = Math.min(h * 0.55, w * 0.32);
    octx.font = `800 ${size}px "Segoe UI", system-ui, sans-serif`;
    let tw = octx.measureText(TEXT).width;
    if (tw > w * 0.9) {
      size *= (w * 0.9) / tw;
      octx.font = `800 ${size}px "Segoe UI", system-ui, sans-serif`;
    }
    octx.fillText(TEXT, w / 2, h / 2);

    const img = octx.getImageData(0, 0, off.width, off.height).data;
    const targets = [];
    for (let y = 0; y < off.height; y += GAP) {
      for (let x = 0; x < off.width; x += GAP) {
        const alpha = img[(y * off.width + x) * 4 + 3];
        if (alpha > 128) targets.push({ x, y });
      }
    }
    buildParticles(targets);
  }

  // ターゲット数に合わせて粒子を生成/再割り当て
  function buildParticles(targets) {
    const next = [];
    for (let i = 0; i < targets.length; i++) {
      const tg = targets[i];
      const p = particles[i] || {
        x: Math.random() * w,
        y: Math.random() * h,
        vx: 0, vy: 0
      };
      p.tx = tg.x; p.ty = tg.y;
      p.hue = 190 + (tg.x / Math.max(1, w)) * 130;
      next.push(p);
    }
    particles = next;
  }

  function update() {
    for (const p of particles) {
      // ターゲットへのバネ力
      let ax = (p.tx - p.x) * 0.045;
      let ay = (p.ty - p.y) * 0.045;
      // マウス近接で反発
      if (mouse.active) {
        const dx = p.x - mouse.x;
        const dy = p.y - mouse.y;
        const dist = Math.hypot(dx, dy);
        if (dist < REPEL && dist > 0.01) {
          const force = (REPEL - dist) / REPEL;
          ax += (dx / dist) * force * 5.5;
          ay += (dy / dist) * force * 5.5;
        }
      }
      p.vx = (p.vx + ax) * 0.86;   // 減衰でゆったり戻る
      p.vy = (p.vy + ay) * 0.86;
      p.x += p.vx;
      p.y += p.vy;
    }
  }

  function render() {
    // 残像を残してトレイル感を出す
    ctx.fillStyle = 'rgba(5, 7, 13, 0.35)';
    ctx.fillRect(0, 0, w, h);
    for (const p of particles) {
      ctx.fillStyle = `hsl(${p.hue}, 90%, 65%)`;
      ctx.fillRect(p.x, p.y, 2, 2);
    }
  }

  function step() {
    if (!running) return;
    if (!reduced) update();
    render();
    rafId = requestAnimationFrame(step);
  }

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

  // ポインタ座標をキャンバス基準に変換
  function setMouse(e) {
    const r = canvas.getBoundingClientRect();
    const pt = e.touches ? e.touches[0] : e;
    if (!pt) return;
    mouse.x = pt.clientX - r.left;
    mouse.y = pt.clientY - r.top;
    mouse.active = true;
  }
  canvas.addEventListener('mousemove', setMouse);
  canvas.addEventListener('mouseleave', () => { mouse.active = false; mouse.x = mouse.y = -9999; });
  canvas.addEventListener('touchmove', (e) => { setMouse(e); }, { passive: true });
  canvas.addEventListener('touchend', () => { mouse.active = false; mouse.x = mouse.y = -9999; });

  resize();
  window.addEventListener('resize', resize);

  // タブ非表示で停止、復帰で再開(rAF二重起動防止)
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) stop(); else start();
  });

  if (reduced) { render(); } else { start(); }
})();

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

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

# 追加してほしい効果
パーティクル文字(Canvas エフェクト)
テキストのピクセルをサンプリングし、その座標へ無数の粒子が集まって文字を形成。マウスを近づけると弾け、離れると吸い寄せられて戻るインタラクティブ表現です。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- パーティクル文字デモ -->
<div class="stage">
  <canvas id="ptCanvas"></canvas>
  <div class="hint">マウスを近づけると粒子が散ります</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(900px 600px at 50% 45%, #0d1424, #05070d 85%);
  cursor: crosshair;
}
#ptCanvas {
  display: block;
  width: 100%;
  height: 100%;
}
/* 下部のヒント */
.hint {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  color: rgba(160, 200, 255, .5);
  font-size: 12px;
  letter-spacing: .06em;
  pointer-events: none;
  user-select: none;
}

【JavaScript】
// パーティクル文字デモ — テキストのピクセルを粒子で再現、マウスで散らす
(() => {
  const canvas = document.getElementById('ptCanvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  let w = 0, h = 0;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  let particles = [];
  let rafId = 0, running = false;
  const mouse = { x: -9999, y: -9999, active: false };
  const TEXT = 'HELLO';
  const REPEL = 46;               // マウス反発半径
  const GAP = 4;                  // ピクセルサンプリング間隔

  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);
    sampleTargets();
  }

  // オフスクリーンにテキストを描いてピクセルをサンプリング
  function sampleTargets() {
    const off = document.createElement('canvas');
    off.width = Math.max(1, w);
    off.height = Math.max(1, h);
    const octx = off.getContext('2d');
    if (!octx) return;
    octx.fillStyle = '#fff';
    octx.textAlign = 'center';
    octx.textBaseline = 'middle';
    // 横幅に収まるフォントサイズを決定
    let size = Math.min(h * 0.55, w * 0.32);
    octx.font = `800 ${size}px "Segoe UI", system-ui, sans-serif`;
    let tw = octx.measureText(TEXT).width;
    if (tw > w * 0.9) {
      size *= (w * 0.9) / tw;
      octx.font = `800 ${size}px "Segoe UI", system-ui, sans-serif`;
    }
    octx.fillText(TEXT, w / 2, h / 2);

    const img = octx.getImageData(0, 0, off.width, off.height).data;
    const targets = [];
    for (let y = 0; y < off.height; y += GAP) {
      for (let x = 0; x < off.width; x += GAP) {
        const alpha = img[(y * off.width + x) * 4 + 3];
        if (alpha > 128) targets.push({ x, y });
      }
    }
    buildParticles(targets);
  }

  // ターゲット数に合わせて粒子を生成/再割り当て
  function buildParticles(targets) {
    const next = [];
    for (let i = 0; i < targets.length; i++) {
      const tg = targets[i];
      const p = particles[i] || {
        x: Math.random() * w,
        y: Math.random() * h,
        vx: 0, vy: 0
      };
      p.tx = tg.x; p.ty = tg.y;
      p.hue = 190 + (tg.x / Math.max(1, w)) * 130;
      next.push(p);
    }
    particles = next;
  }

  function update() {
    for (const p of particles) {
      // ターゲットへのバネ力
      let ax = (p.tx - p.x) * 0.045;
      let ay = (p.ty - p.y) * 0.045;
      // マウス近接で反発
      if (mouse.active) {
        const dx = p.x - mouse.x;
        const dy = p.y - mouse.y;
        const dist = Math.hypot(dx, dy);
        if (dist < REPEL && dist > 0.01) {
          const force = (REPEL - dist) / REPEL;
          ax += (dx / dist) * force * 5.5;
          ay += (dy / dist) * force * 5.5;
        }
      }
      p.vx = (p.vx + ax) * 0.86;   // 減衰でゆったり戻る
      p.vy = (p.vy + ay) * 0.86;
      p.x += p.vx;
      p.y += p.vy;
    }
  }

  function render() {
    // 残像を残してトレイル感を出す
    ctx.fillStyle = 'rgba(5, 7, 13, 0.35)';
    ctx.fillRect(0, 0, w, h);
    for (const p of particles) {
      ctx.fillStyle = `hsl(${p.hue}, 90%, 65%)`;
      ctx.fillRect(p.x, p.y, 2, 2);
    }
  }

  function step() {
    if (!running) return;
    if (!reduced) update();
    render();
    rafId = requestAnimationFrame(step);
  }

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

  // ポインタ座標をキャンバス基準に変換
  function setMouse(e) {
    const r = canvas.getBoundingClientRect();
    const pt = e.touches ? e.touches[0] : e;
    if (!pt) return;
    mouse.x = pt.clientX - r.left;
    mouse.y = pt.clientY - r.top;
    mouse.active = true;
  }
  canvas.addEventListener('mousemove', setMouse);
  canvas.addEventListener('mouseleave', () => { mouse.active = false; mouse.x = mouse.y = -9999; });
  canvas.addEventListener('touchmove', (e) => { setMouse(e); }, { passive: true });
  canvas.addEventListener('touchend', () => { mouse.active = false; mouse.x = mouse.y = -9999; });

  resize();
  window.addEventListener('resize', resize);

  // タブ非表示で停止、復帰で再開(rAF二重起動防止)
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) stop(); else start();
  });

  if (reduced) { render(); } else { start(); }
})();

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

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