モダンウェブ開発におけるダークモード実装 - Reactと各フレームワークの比較

DevRemix
Published at 3/6/2025Updated at 3/9/2025
モダンウェブ開発におけるダークモード実装 - Reactと各フレームワークの比較

こんにちは、Ticklonです。「ダークモード」について技術的な観点から掘り下げてみたいと思います。特にReactを中心に、様々なフレームワークでのダークモード実装方法の違いについて解説します。

なぜダークモードなのか

ダークモードの実装に取り組む前に、その重要性について考えてみましょう。現代のUIデザインにおいて、ダークモードは単なる見た目の問題を超えた意義を持っています。

  1. ユーザー体験の向上: 夜間や暗い環境での目の疲れを軽減
  2. バッテリー消費の削減: 特にOLEDディスプレイでは電力消費を抑える効果がある
  3. アクセシビリティの向上: 光過敏症の方や、明るい画面が苦手なユーザーへの配慮
  4. ブランド価値の向上: モダンで洗練された印象を与える

Reactにおけるダークモード実装の基本

Reactでダークモードを実装する方法はいくつかありますが、最も一般的なアプローチを見ていきましょう。

1. コンテキストAPIを活用したテーマ切り替え

最もクリーンな実装方法の一つがReactのContext APIを使ったテーマ管理です。

// ThemeContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';

type Theme = 'light' | 'dark';
type ThemeContextType = {
  theme: Theme;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC = ({ children }) => {
  // ローカルストレージからテーマ設定を取得、なければシステム設定に従う
  const [theme, setTheme] = useState<Theme>(() => {
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme) return savedTheme as Theme;
    
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  });

  // テーマ切り替え関数
  const toggleTheme = () => {
    setTheme(prevTheme => {
      const newTheme = prevTheme === 'light' ? 'dark' : 'light';
      localStorage.setItem('theme', newTheme);
      return newTheme;
    });
  };

  // DOMにテーマクラスを適用
  useEffect(() => {
    document.documentElement.classList.remove('light', 'dark');
    document.documentElement.classList.add(theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// カスタムフック
export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};

このコンテキストをアプリケーションのルートで使用します:

// App.tsx
import { ThemeProvider } from './ThemeContext';

const App = () => {
  return (
    <ThemeProvider>
      <YourApp />
    </ThemeProvider>
  );
};

そして、テーマ切り替えボタンを実装します:

// ThemeToggle.tsx
import { useTheme } from './ThemeContext';

const ThemeToggle = () => {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? '🌙' : '☀️'}
    </button>
  );
};

2. Tailwind CSSとの統合

私のプロジェクトではTailwind CSSを活用していますが、Tailwindとの組み合わせもシンプルで効果的です。

tailwindcss v4 からはapp.css(ファイル名は各々)でダークモードを設定します:

// app.css
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

tailwindcss v3.4.17 まではtailwind.config.jsでダークモードを設定します:

// tailwind.config.js
module.exports = {
  darkMode: 'class', // 'media'または'class'
  // ...その他の設定
}

ここでdarkMode: 'class'を選択すると、htmlタグにclass="dark"を付与されている場合にダークモードが適用されます。一方、'media'を選択すると、システム設定に基づいて自動的に適用されます。私は'class'を選択することが多いです。ユーザーが明示的に選択できる方が、UXの観点から優れていると考えるからです。

CSSの記述は以下のようにします:

<html class="dark">
  <body>
    <div class="bg-white dark:bg-black">
      <!-- ... -->
    </div>
  </body>
</html>

これでlight/darkテーマの切り替えに応じて、要素のスタイルが変わります。Tailwind CSSのdark:プレフィックスを使用することで、ダークモードの際に適用するスタイルを簡単に指定できます。

様々なフレームワークでのダークモード実装の違い

Reactだけでなく、他のフレームワークでもダークモード実装は異なるアプローチが取られています。それぞれの特徴を見ていきましょう。

Remix.jsでのダークモード実装

私が愛用しているRemix.jsでは、サーバーサイドレンダリングの特性を活かしたアプローチが取れます。

// root.tsx
import { useEffect, useState } from 'react';
import { 
  Links, LiveReload, Meta, Outlet, Scripts, useCatch 
} from '@remix-run/react';

export const meta = () => {
  return { title: 'Your App' };
};

export default function App() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    // クライアント側でローカルストレージから設定を読み込む
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme) {
      setTheme(savedTheme);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      setTheme('dark');
    }
  }, []);

  useEffect(() => {
    document.documentElement.classList.remove('light', 'dark');
    document.documentElement.classList.add(theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <html lang="ja" className={theme}>
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet context={{ theme, setTheme }} />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

Remixではサーバーサイドレンダリングが基本なので、初期レンダリング時にクライアントの設定を知ることができません。そのため、クライアント側でuseEffectを使って初期テーマを設定する必要があります。これにより、初回レンダリング時に一瞬違うテーマが表示されるフラッシュ現象(Flash of Incorrect Theme)が発生することがあります。

これを解決するには、Cookieを使ってサーバーサイドでテーマを判定する方法があります:

// root.tsx (cookie approach)
import { json, LoaderFunction } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

export const loader: LoaderFunction = async ({ request }) => {
  const cookieHeader = request.headers.get('Cookie');
  const cookie = (cookieHeader && parse(cookieHeader)) || {};
  const theme = cookie.theme || 'light';
  
  return json({ theme });
};

この方法ならば、サーバーサイドでHTMLの生成時にすでに正しいテーマが適用されるため、フラッシュ現象を防げます。

Next.jsにおけるダークモード

Next.jsでは、Remixと同様にサーバーサイドレンダリングが重要な役割を果たしますが、App Routerでの実装は少し異なります。

// components/ThemeProvider.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

const ThemeContext = createContext<{
  theme: string;
  toggleTheme: () => void;
}>({
  theme: 'light',
  toggleTheme: () => {},
});

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme) {
      setTheme(savedTheme);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      setTheme('dark');
    }
  }, []);

  useEffect(() => {
    document.documentElement.classList.remove('light', 'dark');
    document.documentElement.classList.add(theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

そして、レイアウトファイルで使用します:

// app/layout.tsx
import { ThemeProvider } from '@/components/ThemeProvider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Next.jsの'use client'ディレクティブを使うことで、クライアントコンポーネントを明示的に指定できます。これはNext.jsのApp Routerの特徴です。

フレームワーク別のダークモード実装の比較表

フレームワーク 特徴 メリット デメリット
React (純粋) Context APIを使用 シンプルで柔軟性が高い すべて自前で実装する必要がある
Remix.js クッキーとサーバーサイドレンダリングの組み合わせ フラッシュ防止が可能 実装がやや複雑
Next.js App Routerと'use client'ディレクティブの活用 サーバーコンポーネントとの統合 クライアントとサーバーの境界を意識する必要がある
Vue.js Composition APIでのリアクティブな実装 Vue独自の簡潔な記法 Reactとは異なるアプローチへの学習が必要
Angular サービスとディレクティブを活用 強力な依存性注入システム 設定が冗長になる傾向がある

CloudflareとJAMstackでのダークモード対応

私はCloudflare Pages上でアプリケーションを展開することが多いですが、JAMstackアーキテクチャでは静的なサイトにもダークモードを実装できます。

Cloudflare Workersを使えば、リクエストヘッダーからユーザーの設定を読み取り、適切なHTMLを返すことも可能です:

// worker.js
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  // リクエストからクッキーを取得
  const cookieHeader = request.headers.get('Cookie') || '';
  const cookies = Object.fromEntries(
    cookieHeader.split('; ').map(c => c.split('='))
  );
  
  // テーマの判定
  const theme = cookies.theme || 'light';
  
  // 元のレスポンスを取得
  const response = await fetch(request);
  const html = await response.text();
  
  // HTMLにテーマクラスを追加
  const modifiedHtml = html.replace(
    '<html',
    `<html class="${theme}"`
  );
  
  return new Response(modifiedHtml, {
    headers: response.headers
  });
}

このアプローチは、静的サイトジェネレーターで生成されたサイトにも適用できます。

まとめ:最適なダークモード実装の選択

ダークモードの実装は、使用するフレームワークや技術スタックに応じて最適なアプローチが異なります。私の経験から、以下のポイントを考慮することをお勧めします:

  1. ユーザー設定の永続化: ローカルストレージやクッキーを使って、ユーザーの設定を保存する
  2. システム設定との連携: prefers-color-schemeメディアクエリを活用して、システム設定をデフォルトとして使用する
  3. フラッシュ防止: サーバーサイドレンダリングの場合、初期レンダリング時のフラッシュ現象を防ぐ工夫をする
  4. CSSの適切な設計: ダークモード用の色をセマンティックな変数として定義する

正直なところ、シンプルなプロジェクトであればTailwind CSSのdarkMode: 'class'設定と、基本的なContextの組み合わせで十分です。しかし、大規模なプロジェクトや特殊な要件がある場合は、フレームワーク固有の機能を活用することで、より効率的な実装が可能になります。

TanaVentの開発では、最初はシンプルなアプローチから始め、ユーザーからのフィードバックを受けて徐々に改良していきました。どのようなプロジェクトでも、まずは基本を押さえた実装から始め、必要に応じて機能を拡張していくことをお勧めします。

明るい画面と暗い画面の両方でテストし、すべてのユーザーにとって快適な体験を提供することを忘れないでください。私たちエンジニアの仕事は、技術だけでなく、それを使用する人々の体験を向上させることにもあるのですから。

次回は「レスポンシブデザインのベストプラクティス」について書いてみようと思います。それでは、また。

Ticklon