パーティクル文字
テキストのピクセルをサンプリングし、その座標へ無数の粒子が集まって文字を形成。マウスを近づけると弾け、離れると吸い寄せられて戻るインタラクティブ表現です。
ライブデモ
使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。