Canvas APIでパーティクル背景を作る — ジェネレーティブデザイン入門
開発者ポートフォリオや技術ブログでよく見る、粒子が浮遊して近くの粒子同士が線で繋がるアレ。外部ライブラリなしで、Canvas API + バニラJavaScriptだけで作れる。この記事では実際にmiyashi.appのヒーローセクションに実装したコードをベースに、ステップバイステップで解説する。
ジェネレーティブデザインとは
アルゴリズムや数学的なルールでビジュアルを自動生成するデザイン手法。パーティクルシステム、フラクタル、セルオートマトンなどがある。今回作るのはパーティクル+接続線のパターン。ノードが浮遊して、近いノード同士が線で結ばれるネットワーク風のアニメーション。
💡 外部ライブラリ(three.js, p5.js等)を使わなくても、Canvas APIだけで十分リッチな表現ができる。バンドルサイズを増やさずにすむのがメリット。
完成イメージ
このサイトのトップページを開くと、ヘッダーの下にダークグラデーションの背景にパーティクルが浮遊しているのが見える。それが今回作るもの。
Step 1: Canvas要素のセットアップ
まずReactコンポーネントにCanvas要素を置く。CSSで親要素いっぱいに広げる。
import { useEffect, useRef } from 'react'
const Hero = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
return (
<section style={{ position: 'relative', height: '340px', background: '#0f172a' }}>
<canvas ref={canvasRef} style={{ position: 'absolute', width: '100%', height: '100%' }} />
<div style={{ position: 'relative', zIndex: 1, color: '#fff', textAlign: 'center' }}>
<h1>miyashi.app</h1>
</div>
</section>
)
}
📝 Next.js(SSG)ではCanvasの操作はuseEffect内で行う。サーバーサイドレンダリング時にはwindowやdocumentが存在しないため。
Step 2: Retina対応のCanvas初期化
Canvas要素の解像度をデバイスのピクセル比に合わせないと、Retinaディスプレイでぼやける。
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const resize = () => {
const dpr = window.devicePixelRatio || 1
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
ctx.scale(dpr, dpr)
}
resize()
window.addEventListener('resize', resize)
return () => window.removeEventListener('resize', resize)
}, [])
ポイント: canvas.width/height(実際のピクセル数)とcanvas.offsetWidth/offsetHeight(CSSサイズ)は別物。dpr倍の解像度を確保してからctx.scale(dpr, dpr)で描画座標を合わせる。
Step 3: パーティクルの生成
各パーティクルは位置(x, y)、速度(vx, vy)、半径を持つ。
type Particle = {
x: number
y: number
vx: number
vy: number
radius: number
}
const count = 80
const particles: Particle[] = []
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * canvas.offsetWidth,
y: Math.random() * canvas.offsetHeight,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
radius: Math.random() * 1.5 + 0.5
})
}
速度は-0.25〜+0.25のランダム値。遅いほうが上品な印象になる。半径は0.5〜2.0でバラつかせる。
Step 4: アニメーションループ
requestAnimationFrameで毎フレーム描画する。パーティクルを移動させ、画面端でバウンドさせる。
const animate = () => {
const w = canvas.offsetWidth
const h = canvas.offsetHeight
ctx.clearRect(0, 0, w, h)
for (const p of particles) {
// 移動
p.x += p.vx
p.y += p.vy
// 画面端でバウンド
if (p.x < 0 || p.x > w) p.vx *= -1
if (p.y < 0 || p.y > h) p.vy *= -1
// パーティクル描画
ctx.fillStyle = 'rgba(99, 102, 241, 0.4)'
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2)
ctx.fill()
}
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
⚡ clearRectで前フレームの描画をクリアするのを忘れずに。これがないとパーティクルの残像がどんどん重なっていく。
Step 5: 接続線の描画
ここが見た目のキモ。パーティクル同士の距離が一定以内なら、距離に応じた透明度で線を引く。近いほど濃く、遠いほど薄い。
const connectionDistance = 150
for (let i = 0; i < particles.length; i++) {
const p = particles[i]
for (let j = i + 1; j < particles.length; j++) {
const q = particles[j]
const dx = p.x - q.x
const dy = p.y - q.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < connectionDistance) {
const alpha = 1 - dist / connectionDistance
ctx.strokeStyle = `rgba(99, 102, 241, ${alpha * 0.15})`
ctx.lineWidth = 0.5
ctx.beginPath()
ctx.moveTo(p.x, p.y)
ctx.lineTo(q.x, q.y)
ctx.stroke()
}
}
}
j = i + 1から始めているのは、同じペアを二重に処理しないため。計算量はO(n^2)だが、80個程度なら余裕。
Step 6: レスポンシブ+パフォーマンス最適化
モバイルではパーティクル数と接続距離を減らす。
const isMobile = window.innerWidth < 768
const count = isMobile ? 40 : 80
const connectionDistance = isMobile ? 100 : 150
💡 パーティクル数を半分にするだけで接続線の計算量は約1/4になる(n^2のため)。モバイルで60fpsをキープするには重要な最適化。
Step 7: prefers-reduced-motion対応
アクセシビリティとして、ユーザーが「視差効果を減らす」設定をオンにしている場合は静止画にフォールバックする。
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (prefersReducedMotion) {
// 1フレームだけ描画して停止
// パーティクルと接続線を描画するが、アニメーションループは回さない
drawStaticFrame(ctx, particles, connectionDistance)
return
}
// 通常のアニメーションループ
requestAnimationFrame(animate)
アニメーションは全く動かさず、パーティクルと接続線の初期配置だけを描画する。
Step 8: クリーンアップ
ReactのuseEffectではクリーンアップが重要。アニメーションフレームとイベントリスナーを解除する。
useEffect(() => {
let animId: number
// ... setup ...
animId = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(animId)
window.removeEventListener('resize', resize)
}
}, [])
⚠️ cancelAnimationFrameを忘れるとコンポーネントがアンマウントされた後もアニメーションが回り続け、メモリリークの原因になる。
カスタマイズのヒント
パラメータを変えるだけで印象が大きく変わる。
| パラメータ | 効果 |
|---|---|
| パーティクル数 | 増やすと密に、減らすとスカスカに |
| 速度 | 遅いと上品、速いとにぎやか |
| 接続距離 | 大きいと線が多く複雑に |
| 線の透明度 | 薄くすると繊細、濃くすると主張が強い |
| 色 | indigo系でテック感、green系でマトリックス風 |
| 粒子の半径 | 小さいとシャープ、大きいとソフト |
まとめ
- Canvas APIだけでパーティクル+接続線のジェネレーティブ背景が作れる
requestAnimationFrameでスムーズなアニメーション- Retina対応は
devicePixelRatioで解像度を合わせる - モバイルではパーティクル数を減らして
O(n^2)を軽減 prefers-reduced-motionに対応するのがアクセシビリティのマナー- Next.js SSGでは
useEffect内でCanvasを操作する
外部ライブラリなし、100行ちょっとのコードで作れるので、ポートフォリオや技術ブログの背景にぜひ。