Next.jsとCanvasで雪を降らせるエフェクト作り ❄️

2025 05 25
17

Next.jsとCanvasでブログにインタラクティブな雪のエフェクトを実装する ❄️

https://appllio.com/line-snow-effect-christmashttps://appllio.com/line-snow-effect-christmas
毎年冬、特にクリスマスシーズンになると、メッセンジャーのチャットルームに綺麗に雪が降るエフェクトを見たことがあると思います。
それを見るたびに、いつもあのようなアニメーション効果を作ってみたいと思っていました。単に背景画像を敷くだけでなく、動的な効果を与えることが、現実との相互作用をより大きく感じさせてくれると考えたからです。

そこで今回は、Next.js、TailwindCSS、そしてHTML Canvasを活用して、ページ全体に雪が降るエフェクトを作ってみました。

🎯 目標と技術

目標:

  • ページ全体に雪が降る視覚的効果を実装する。
  • 雪のエフェクトはページコンテンツの上にオーバーレイされるが、マウスクリックやスクロールなど、既存のページのインタラクションを妨げないようにする。
  • ブラウザのウィンドウサイズが変更されても自然に反応するようにする。
  • mdx環境で再利用可能なReactコンポーネントとして作成する。

技術スタック:

  • HTML5 Canvas: 数多くの雪の結晶を効率的に描画し、アニメーション処理するために選択しました。DOM要素を直接操作するよりもパフォーマンス面で有利です。

🛠️ 実装プロセスの詳細

雪を降らせるエフェクトは、Snowfall.tsxという単一のReactコンポーネントで実装しました。(全コードはこの記事の最後、または以前の会話のCanvasで確認できます。)

ステップ1: Canvasコンポーネントの基本構造を作成

まずSnowfall.tsxというファイルを作成し、基本的なReactコンポーネントの構造を作ります。

// components/Snowfall.tsx
"use client" // useEffectが使用されるため、クライアントコンポーネントであることを明示します。
 
import { useEffect, useRef } from 'react'
 
// 雪の結晶のプロパティインターフェース
interface Snowflake { /* ... */ }
 
const Snowfall: React.FC = () => {
	const canvasRef = useRef<HTMLCanvasElement | null>(null)
 
	useEffect(() => {
		const canvasInstance = canvasRef.current
		const contextInstance = canvasInstance?.getContext('2d')
 
		if (canvasInstance && contextInstance) {
			// code here!
		}
	}, [])
 
	return (
		<canvas
			ref={canvasRef}
			className="fixed top-0 left-0 w-screen h-screen pointer-events-none z-[999]"
		/>
	)
}
 
export default Snowfall
  • "use client": Next.js App Router環境でuseEffectuseRefのようなクライアントサイドのフックを使用するために必須です。
  • useRef<HTMLCanvasElement | null>(null): CanvasのDOM要素に直接アクセスするためにuseRefを使用します。
  • useEffect: コンポーネントがマウントされた後にCanvas関連のロジックを実行します。
  • <canvas>要素: 画面全体に固定し、z-indexを設定して最前面に配置します。pointer-events-noneで他のコンポーネントのマウスイベントを妨げないようにします。

ステップ2: 雪の結晶の定義と生成

雪の結晶の形状と状態を定義するSnowflakeインターフェースを作成し、このインターフェースに従って複数の雪の結晶オブジェクトを生成する関数を作成します。

interface Snowflake {
	x: number
	y: number
	radius: number
	density: number // 雪の結晶の動きの多様性のための値
	color: string	 // 雪の結晶の色
}
 
let snowflakes: Snowflake[] = []
const numSnowflakes: number = 150 // 雪の結晶の数(調整可能)
 
const createSnowflakes = (): void => {
	snowflakes = []
	for (let i = 0; i < numSnowflakes; i++) {
		snowflakes.push({
			// 初期位置をランダムに生成
			x: Math.random() * canvasInstance.width,
			y: Math.random() * canvasInstance.height,
			radius: Math.random() * 3 + 1,			 // 大きさ (1px ~ 4px)
			density: Math.random() * numSnowflakes,
			color: 'rgba(255, 255, 255, 0.8)',	   // 白色、やや透明
		})
	}
}

各雪の結晶は、ランダムなx, y位置、半径(大きさ)、密度(動きのパターンに影響)、色を持ちます。

ステップ3: 雪の結晶の描画とアニメーション

次に、生成された雪の結晶をCanvasに描画します。

雪の結晶の描画 (drawSnowflakes):

const drawSnowflakes = (): void => {
	contextInstance.clearRect(0, 0, canvasInstance.width, canvasInstance.height) // 重要:前のフレームを消去
	for (let i = 0; i < snowflakes.length; i++) {
		const s = snowflakes[i]
		contextInstance.fillStyle = s.color
		contextInstance.beginPath() // 重要:この雪の結晶だけのための新しいパスを開始
		contextInstance.arc(s.x, s.y, s.radius, 0, Math.PI * 2, true) // 円形の雪の結晶
		contextInstance.fill()
	}
	updateSnowflakes() // 描画後、次の位置に更新
}

毎フレームclearRectで全てのフレームを消去し、再度描画することで雪が降るアニメーションを演出します。arcメソッドを使用して円形の雪の結晶を表現しました。

雪の結晶の位置更新 (updateSnowflakes):

const updateSnowflakes = (): void => {
	for (let i = 0; i < snowflakes.length; i++) {
		const s = snowflakes[i]
		s.y += Math.pow(s.radius, 0.5) * 0.5 + 1 // 下に落ちる(大きい雪が少し速く)
		s.x += Math.sin(s.density * 0.02) * s.radius * 0.1 // 左右に落ちる方向
 
		// 画面の下に外れたら、再び上から開始
		if (s.y > canvasInstance.height + s.radius) {
			snowflakes[i].x = Math.random() * canvasInstance.width
			snowflakes[i].y = -s.radius
		}
		// 画面の左右に外れたら、反対側から開始
		if (s.x > canvasInstance.width + s.radius) {
			// right -> left
			snowflakes[i].x = -s.radius
		} else if (s.x < -s.radius) {
			// left -> right
			snowflakes[i].x = canvasInstance.width + s.radius
		}
	}
}

y座標を増加させて下に落ちるようにし、Math.sinを利用して左右の動きを加えて自然さを出しました。
画面外に出た雪の結晶は、再び画面の上部や左右から現れるように処理します。

アニメーションループ (animate):

let animationFrameId: number
const animate = (): void => {
	drawSnowflakes()
	animationFrameId = requestAnimationFrame(animate) // 再帰呼び出しでループを生成
}

requestAnimationFrameは、ブラウザの次の再描画のタイミングでanimate関数を再び呼び出し、滑らかなアニメーションを作成します。

ステップ4: レスポンシブ対応 (リサイズ対応)

ブラウザのウィンドウサイズが変更されると、Canvasのサイズも一緒に変更し、雪の結晶を新しいサイズに合わせて再配置します。

const setCanvasSize = () => {
	canvasInstance.width = window.innerWidth
	canvasInstance.height = window.innerHeight
}
 
const handleResize = (): void => {
	setCanvasSize()
	createSnowflakes() // 雪の結晶を新しいサイズに合わせて再生成
}
 
// useEffect内の初期設定およびイベントリスナー登録
setCanvasSize()
createSnowflakes()
animate()
window.addEventListener('resize', handleResize)
 
// useEffectのクリーンアップ関数でイベントリスナーを削除
return () => {
	window.removeEventListener('resize', handleResize)
	if (animationFrameId) {
		cancelAnimationFrame(animationFrameId)
	}
}

直面した問題と解決策 (Troubleshooting)

🏔 問題1: 雪崩が発生!

最初に雪の結晶をループで複数描画したところ、意図しない描画の問題に直面しました。すべての雪の結晶が一つのかたまりのようになり、まるで画面が壊れたように見えました。

  • 原因: Canvasでパスを描画する命令(arc, lineTo, moveToなど)は、基本的に累積されます。
    連続して図形を描画すると、以前に描画した図形のパス情報が残り、次の図形に影響を与え、Canvasがこれらのすべての円を一つのパスとして認識して塗りつぶしてしまいます。

  • 誤った試み (例):

    // 各雪の結晶を描画する際にbeginPath()がなかったら?
    for (let i = 0; i < snowflakes.length; i++) {
    	const s = snowflakes[i];
    	contextInstance.fillStyle = s.color;
    	// contextInstance.beginPath(); // <- この行がないと仮定!
    	contextInstance.arc(s.x, s.y, s.radius, 0, Math.PI * 2, true);
    	contextInstance.fill(); // fill()をループ内で呼び出しても問題が発生することがある
    								// 特にfillStyleが同じで、clearRectがなければ重なって描画される
    								// さらに大きな問題は、fill()をループの外で一度だけ呼び出す場合に発生
    }
    // もしfill()をループの外で一度だけ呼び出した場合
    // 全てのarcが一つのパスとして結合され、予期せぬ結果になる
  • 解決: 各雪の結晶を独立した図形として描画するには、各雪の結晶を描画する直前にcontextInstance.beginPath()を呼び出します。
    この関数は「これから全く新しい描画パスを開始するよ!」とCanvasに知らせる役割を果たします。これにより、前の雪の結晶のパスが次の雪の結晶に影響を与えず、各雪の結晶がクリーンかつ正確に描画されます。

    // Snowfall.tsxのdrawSnowflakes関数内部
    function drawSnowflakes(): void {
    	contextInstance.clearRect(0, 0, canvasInstance.width, canvasInstance.height);
    	for (let i = 0; i < snowflakes.length; i++) {
    		const s = snowflakes[i];
    		contextInstance.fillStyle = s.color;
     
    		contextInstance.beginPath(); // 雪の結晶ごとに新しいパスを開始!
     
    		contextInstance.arc(s.x, s.y, s.radius, 0, Math.PI * 2, true);
    		contextInstance.fill(); // 現在定義されているパス(この雪の結晶)だけを塗りつぶす
    	}
    	updateSnowflakes();
    }

🥶 問題2: オーバーレイ!

Canvasが画面全体を覆ってしまい、その下にあるヘッダーやTOCなどがクリックできない現象がありました。

  • 原因: Canvas要素が最上層に位置し、すべてのマウスイベントを横取りしていたためです。
  • 解決: CSSのpointer-eventsプロパティを使用しました。Canvas要素にpointer-events-noneスタイルを適用すると、その要素はマウスイベントを無視し、イベントがその下にある要素に渡されます。Tailwind CSSではclassName="... pointer-events-none ..."のように簡単に適用できます。

🚫 問題3: Next.jsで「useEffectはクライアントコンポーネントでのみ動作します」エラー

MDXファイルにSnowfallコンポーネントを挿入したところ、次のようなエラーが発生しました:
You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.

  • 原因: Next.js 13+ App Router (または特定のPages Router設定) では、コンポーネントはデフォルトでサーバーコンポーネントとして扱われます。サーバーコンポーネントはサーバーでレンダリングされるため、useEffectuseStateのようにブラウザ環境に依存するフックを直接使用できません。SnowfallコンポーネントはuseEffectuseRefを使用するため、クライアントサイドで実行される必要があります。

  • 解決: Snowfall.tsxファイルの最初の行に"use client"ディレクティブを追加しました。このディレクティブは、Next.jsにそのファイルおよびそのファイルからエクスポートされるすべてのコンポーネントがクライアントコンポーネントであることを伝えます。

    // components/Snowfall.tsx
    "use client" // まさにこの部分!
     
    import { useEffect, useRef } from 'react'
    // ...

😵 問題4: 白い背景に白い雪の結晶が見えない! (ダークモード強制適用)

雪の結晶を白(rgba(255, 255, 255, 0.8))で作成したところ、私のブログのデフォルトのライトテーマでは、雪の結晶が背景に埋もれて全く見えないという問題がありました。雪が降る効果をきちんと見せるためには、暗い背景が必要でした。

  • 初期の試み: SnowfallコンポーネントのuseEffect内でlocalStorage.setItem('theme', 'dark')コードを追加してダークモードを誘導してみました。

  • 問題点: localStorageの値だけを変更しても、すぐにはテーマが変わりませんでした。リフレッシュして初めてダークモードが適用されましたが、これはユーザー体験上良くありませんでした。DOMの即時的な変化を引き起こさないためです。

  • 解決: Snowfallコンポーネントがマウントされる際に即座にダークモードを適用し、アンマウントされる際に元のテーマ設定に復元するようにロジックを修正しました。

    useEffect(() => {
    	const htmlElement = document.documentElement
    	const originalThemeInStorage = localStorage.getItem('theme')
    	const hasHtmlDarkTheme = htmlElement.classList.contains('dark')
     
    	// 1. ダークモードのテーマを保存
    	localStorage.setItem('theme', 'dark')
     
    	// 2. 即時的な視覚的変更のために'dark'クラスを直接追加
    	if (!hasHtmlDarkTheme) {
    		htmlElement.classList.add('dark')
    	}
     
    	// ... (既存のCanvasアニメーションロジック) ...
     
    	// useEffectのクリーンアップ関数(コンポーネントのアンマウント時)
    	return () => {
    		// ... (Canvasアニメーション関連のクリーンアップ) ...
     
    		// テーマ復元ロジック
    		if (!hasHtmlDarkTheme) {
    			// Snowfallコンポーネントが'dark'クラスを追加した場合、削除します。
    			htmlElement.classList.remove('dark')
    		}
     
    		// localStorageのテーマ値を元に戻します。
    		if (originalThemeInStorage) {
    			localStorage.setItem('theme', originalThemeInStorage)
    		} else {
    			localStorage.removeItem('theme') // 元のテーマ設定がなかった場合は削除
    		}
    		// もしアプリケーション全体のテーマシステムがlocalStorageの変更に反応する場合
    		// この時点で再び元のテーマに戻るはずです。
    	}
    }, [])
    // ...

このページにアクセスしてSnowfallコンポーネントが動作すると、ダークモードが適用されて雪の結晶がよく見えるようにしました。他のページに移動してコンポーネントが非表示になると、元のテーマ設定に戻るように処理しました。

MDXへの適用

SnowfallコンポーネントをMdxComponentsに登録し、MDXファイルで簡単にインポートして使用できるようにしました。

// components/mdx/index.tsx
 
export const MdxComponents: MDXComponents = {
	a: ExternalLink as any,
	img: Image as any,
	blockquote: Callout,
	Callout,
	// codeLab
	Snowfall,
}

MDXファイルでも、通常のReactコンポーネントのように<Snowfall />を挿入すれば大丈夫です。

最後に

Next.jsとHTML Canvasを使用して、インタラクティブでありながらユーザー体験を損なわない雪のエフェクトを作成してみました。簡単に見えるかもしれませんが、実際の実装過程ではCSSの動作方式、アニメーション、自然な動き、Next.jsのレンダリングなど、様々な理解が必要でした。

💡 追加の改善アイデア:

  • 多様な雪の結晶の形: 円形の代わりに画像や他の図形を使用する
  • 風の効果: 雪の結晶が一方に舞う効果を追加する
  • ユーザーインタラクション: マウスカーソルやボタンで雪の結晶が散らばる効果