3/2

2026

Canvas APIでパーティクル背景を作る — ジェネレーティブデザイン入門

#Canvas API#ジェネレーティブデザイン#JavaScript#Next.js#アニメーションCanvas APIでパーティクル背景を作る — ジェネレーティブデザイン入門

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.52.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行ちょっとのコードで作れるので、ポートフォリオや技術ブログの背景にぜひ。