T-CREATOR

Tailwind CSSでのダークモード対応するための実装手順と注意点

Tailwind CSSでのダークモード対応するための実装手順と注意点

現代のウェブアプリケーションにおいて、ダークモード対応は単なるトレンドではなく、ユーザビリティとアクセシビリティの観点から必須の機能となりつつあります。目の疲労軽減、バッテリー消費の削減、そして何よりユーザーの好みに応じた快適な閲覧体験を提供するダークモードは、多くのユーザーに愛されています。Tailwind CSS を使えば、この重要な機能を効率的かつ美しく実装することができるでしょう。本記事では、Tailwind CSS を用いたダークモードの基礎知識から実装手順、さらには実際の開発で遭遇しがちな問題とその解決策まで、網羅的に解説いたします。初心者の方でも安心して取り組めるよう、段階的に進めていきますので、ぜひ最後までお付き合いください。

Tailwind CSS のダークモード基礎知識

Tailwind CSS におけるダークモードは、dark: バリアントを使用して実現されます。これは、ユーザーのシステム設定や手動での切り替えに応じて、異なるスタイルを適用する仕組みです。従来の CSS では、メディアクエリやクラスの切り替えを手動で管理する必要がありましたが、Tailwind CSS ではより直感的で保守性の高い方法でダークモードを実装できます。

Tailwind CSS のダークモード実装には、主に 2 つのアプローチがあります。一つは media ストラテジーで、これはユーザーのオペレーティングシステムの設定(prefers-color-scheme: dark)に基づいて自動的にダークモードを適用する方法です。もう一つは class ストラテジーで、HTML 要素にクラスを追加することで手動でダークモードを制御する方法になります。

media ストラテジーは実装が簡単で、ユーザーのシステム設定を尊重できる利点があります。しかし、ユーザーが手動でテーマを切り替えたい場合や、システム設定とは異なる設定を望む場合には対応できません。一方、class ストラテジーは実装がやや複雑になりますが、より柔軟な制御が可能で、ユーザーに選択の自由を提供できます。

現代のウェブアプリケーションでは、ユーザー体験の向上を重視する観点から、class ストラテジーを採用することが一般的です。これにより、ユーザーはシステム設定に関係なく、好みに応じてテーマを選択できるようになります。また、状態管理やローカルストレージとの連携も容易になり、より高度な機能を実装することが可能です。

Tailwind CSS のダークモードバリアントは、色に関するユーティリティだけでなく、境界線、影、背景画像など、あらゆるスタイルプロパティに適用できます。例えば、bg-white dark:bg-gray-900 といった書き方で、ライトモードでは白い背景、ダークモードでは濃いグレーの背景を指定できます。この一貫したバリアントシステムにより、開発者は直感的にダークモード対応のスタイルを記述できるのです。

重要な点として、ダークモード実装時には、単純に色を反転させるだけでは不十分です。コントラスト比の確保、読みやすさの維持、ブランドアイデンティティの保持など、デザインの観点から慎重に検討する必要があります。Tailwind CSS は、これらの課題に対応するための豊富なカラーパレットと、細かな調整が可能なユーティリティクラスを提供しています。

設定ファイルの準備と基本的な実装手順

ダークモード対応の第一歩は、tailwind.config.js ファイルでの設定から始まります。新しいプロジェクトの場合、まずは Tailwind CSS の基本的なセットアップを完了させましょう。

javascript// tailwind.config.js
module.exports = {
  darkMode: 'class', // または 'media'
  content: [
    './src/**/*.{js,ts,jsx,tsx}',
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {
      colors: {
        // カスタムダークモード用カラーの定義(オプション)
        'dark-primary': '#1a202c',
        'dark-secondary': '#2d3748',
        'dark-accent': '#4a5568',
      },
    },
  },
  plugins: [],
};

darkMode: 'class' の設定により、HTML 要素に dark クラスが追加されたときにダークモードスタイルが適用されるようになります。この設定は、手動でのテーマ切り替えや、JavaScript による動的な制御を可能にする重要な設定です。

次に、HTML のルート要素にダークモード制御のための基本構造を準備します。Next.js を使用している場合、_app.tsxlayout.tsx でグローバルな設定を行います。

typescript// pages/_app.tsx (App Router の場合は app/layout.tsx)
import { useState, useEffect } from 'react';
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  const [darkMode, setDarkMode] = useState(false);

  useEffect(() => {
    // 初期テーマの設定
    const savedTheme = localStorage.getItem('theme');
    const prefersDark = window.matchMedia(
      '(prefers-color-scheme: dark)'
    ).matches;

    const initialDarkMode =
      savedTheme === 'dark' || (!savedTheme && prefersDark);
    setDarkMode(initialDarkMode);

    // HTML要素にクラスを適用
    if (initialDarkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, []);

  const toggleDarkMode = () => {
    const newDarkMode = !darkMode;
    setDarkMode(newDarkMode);

    if (newDarkMode) {
      document.documentElement.classList.add('dark');
      localStorage.setItem('theme', 'dark');
    } else {
      document.documentElement.classList.remove('dark');
      localStorage.setItem('theme', 'light');
    }
  };

  return (
    <div className={darkMode ? 'dark' : ''}>
      <Component
        {...pageProps}
        toggleDarkMode={toggleDarkMode}
        darkMode={darkMode}
      />
    </div>
  );
}

export default MyApp;

CSS ファイル(通常は globals.css)では、基本的なダークモードスタイルを定義します。Tailwind CSS の @apply ディレクティブを活用することで、保守性の高いスタイル定義が可能です。

css/* styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  html {
    @apply transition-colors duration-300;
  }

  body {
    @apply bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100;
  }
}

@layer components {
  .theme-transition {
    @apply transition-colors duration-300 ease-in-out;
  }
}

この基本設定により、ページ全体がダークモードに対応し、テーマ切り替え時に滑らかなトランジション効果も実現されます。theme-transition クラスは、個々のコンポーネントで再利用できる便利なユーティリティとして活用できるでしょう。

プロジェクトの初期セットアップが完了したら、実際にコンポーネントレベルでダークモード対応を進めていきます。まずは、シンプルなボタンコンポーネントから始めてみましょう。

typescript// components/Button.tsx
interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary';
  onClick?: () => void;
}

const Button: React.FC<ButtonProps> = ({
  children,
  variant = 'primary',
  onClick,
}) => {
  const baseClasses =
    'px-4 py-2 rounded-lg font-medium transition-colors duration-200';

  const variantClasses = {
    primary:
      'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white',
    secondary:
      'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100',
  };

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

export default Button;

このように、各バリアントに対応するダークモードスタイルを併記することで、統一感のあるダークモード対応が実現できます。

dark:バリアントを使った効果的なスタイリング方法

Tailwind CSS の dark: バリアントを効果的に活用するためには、色の選択とコントラストの管理が重要です。単純に明るい色を暗い色に置き換えるだけでは、読みやすさやアクセシビリティの問題が生じる可能性があります。

まず、効果的なダークモード配色の原則を理解しましょう。ダークモードでは、完全な黒(#000000)よりも少し明るいダークグレーを背景色として使用することが推奨されます。これにより、目の疲労を軽減し、コンテンツの読みやすさを向上させることができます。

typescript// components/Card.tsx
interface CardProps {
  title: string;
  content: string;
  featured?: boolean;
}

const Card: React.FC<CardProps> = ({
  title,
  content,
  featured = false,
}) => {
  return (
    <div
      className={`
      p-6 rounded-xl shadow-lg theme-transition
      ${
        featured
          ? 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-700 border-blue-200 dark:border-gray-600'
          : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
      }
      border hover:shadow-xl
    `}
    >
      <h3 className='text-xl font-semibold mb-3 text-gray-900 dark:text-gray-100'>
        {title}
      </h3>
      <p className='text-gray-600 dark:text-gray-300 leading-relaxed'>
        {content}
      </p>

      {/* アクションボタンの例 */}
      <div className='mt-4 flex gap-2'>
        <button
          className='
          px-3 py-1 text-sm rounded-md
          bg-blue-100 dark:bg-blue-900 
          text-blue-700 dark:text-blue-300
          hover:bg-blue-200 dark:hover:bg-blue-800
          transition-colors duration-200
        '
        >
          詳細を見る
        </button>
        <button
          className='
          px-3 py-1 text-sm rounded-md
          bg-gray-100 dark:bg-gray-700 
          text-gray-700 dark:text-gray-300
          hover:bg-gray-200 dark:hover:bg-gray-600
          transition-colors duration-200
        '
        >
          共有
        </button>
      </div>
    </div>
  );
};

export default Card;

フォームコンポーネントでは、特に入力要素のスタイリングに注意が必要です。ダークモードでは、フォーカス状態やエラー状態の表示方法も適切に調整する必要があります。

typescript// components/Input.tsx
interface InputProps {
  label: string;
  type?: string;
  placeholder?: string;
  error?: string;
  value: string;
  onChange: (value: string) => void;
}

const Input: React.FC<InputProps> = ({
  label,
  type = 'text',
  placeholder,
  error,
  value,
  onChange,
}) => {
  return (
    <div className='space-y-2'>
      <label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
        {label}
      </label>
      <input
        type={type}
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        className={`
          w-full px-3 py-2 rounded-lg border theme-transition
          bg-white dark:bg-gray-800
          text-gray-900 dark:text-gray-100
          placeholder-gray-400 dark:placeholder-gray-500
          ${
            error
              ? 'border-red-300 dark:border-red-600 focus:border-red-500 dark:focus:border-red-400'
              : 'border-gray-300 dark:border-gray-600 focus:border-blue-500 dark:focus:border-blue-400'
          }
          focus:ring-2 focus:ring-opacity-50
          ${
            error
              ? 'focus:ring-red-200 dark:focus:ring-red-800'
              : 'focus:ring-blue-200 dark:focus:ring-blue-800'
          }
          focus:outline-none
        `}
      />
      {error && (
        <p className='text-sm text-red-600 dark:text-red-400'>
          {error}
        </p>
      )}
    </div>
  );
};

export default Input;

ナビゲーションコンポーネントでは、現在のページを示すアクティブ状態や、ホバー効果をダークモードでも適切に表現する必要があります。

typescript// components/Navigation.tsx
interface NavigationItem {
  name: string;
  href: string;
  active?: boolean;
}

const Navigation: React.FC<{ items: NavigationItem[] }> = ({
  items,
}) => {
  return (
    <nav
      className='
      bg-white dark:bg-gray-900 
      border-b border-gray-200 dark:border-gray-700
      theme-transition
    '
    >
      <div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'>
        <div className='flex justify-between h-16'>
          <div className='flex space-x-8'>
            {items.map((item) => (
              <a
                key={item.name}
                href={item.href}
                className={`
                  inline-flex items-center px-1 pt-1 text-sm font-medium theme-transition
                  ${
                    item.active
                      ? 'border-b-2 border-blue-500 dark:border-blue-400 text-gray-900 dark:text-gray-100'
                      : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-b-2 hover:border-gray-300 dark:hover:border-gray-600'
                  }
                `}
              >
                {item.name}
              </a>
            ))}
          </div>
        </div>
      </div>
    </nav>
  );
};

export default Navigation;

これらの例から分かるように、dark: バリアントを効果的に使用するには、以下のポイントを意識することが重要です:

  1. 階層的なコントラスト: 背景、コンテンツ、アクセント色の間に適切な明度差を設ける
  2. 状態の表現: ホバー、フォーカス、アクティブ状態をダークモードでも明確に表現
  3. 読みやすさの確保: テキストと背景のコントラスト比を WCAG ガイドラインに準拠させる
  4. 一貫性の維持: サイト全体で統一された色彩ルールを適用

JavaScript 連携による動的テーマ切り替えの実装

ユーザーフレンドリーなダークモード実装には、JavaScript による動的な制御が不可欠です。単純なクラスの切り替えだけでなく、ユーザーの設定保存、システム設定との連携、スムーズなトランジション効果なども考慮する必要があります。

まず、テーマ管理のためのカスタムフックを作成しましょう。これにより、アプリケーション全体で一貫したテーマ制御が可能になります。

typescript// hooks/useTheme.ts
import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark' | 'system';

export const useTheme = () => {
  const [theme, setTheme] = useState<Theme>('system');
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    // 保存されたテーマ設定を読み込み
    const savedTheme = localStorage.getItem(
      'theme'
    ) as Theme;
    if (
      savedTheme &&
      ['light', 'dark', 'system'].includes(savedTheme)
    ) {
      setTheme(savedTheme);
    }
  }, []);

  useEffect(() => {
    const updateTheme = () => {
      const systemPrefersDark = window.matchMedia(
        '(prefers-color-scheme: dark)'
      ).matches;
      const shouldBeDark =
        theme === 'dark' ||
        (theme === 'system' && systemPrefersDark);

      setIsDark(shouldBeDark);

      if (shouldBeDark) {
        document.documentElement.classList.add('dark');
      } else {
        document.documentElement.classList.remove('dark');
      }
    };

    updateTheme();

    // システムテーマの変更を監視
    const mediaQuery = window.matchMedia(
      '(prefers-color-scheme: dark)'
    );
    const handleSystemThemeChange = () => {
      if (theme === 'system') {
        updateTheme();
      }
    };

    mediaQuery.addEventListener(
      'change',
      handleSystemThemeChange
    );

    return () => {
      mediaQuery.removeEventListener(
        'change',
        handleSystemThemeChange
      );
    };
  }, [theme]);

  const changeTheme = (newTheme: Theme) => {
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  return {
    theme,
    isDark,
    changeTheme,
    toggleTheme: () =>
      changeTheme(isDark ? 'light' : 'dark'),
  };
};

このカスタムフックを使用したテーマ切り替えコンポーネントを作成します。ユーザーにとって分かりやすい UI を提供することが重要です。

typescript// components/ThemeToggle.tsx
import { useTheme } from '../hooks/useTheme';
import {
  SunIcon,
  MoonIcon,
  ComputerDesktopIcon,
} from '@heroicons/react/24/outline';

const ThemeToggle: React.FC = () => {
  const { theme, isDark, changeTheme } = useTheme();

  const themeOptions = [
    { value: 'light', label: 'ライト', icon: SunIcon },
    { value: 'dark', label: 'ダーク', icon: MoonIcon },
    {
      value: 'system',
      label: 'システム',
      icon: ComputerDesktopIcon,
    },
  ];

  return (
    <div className='relative'>
      <div className='flex items-center space-x-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1'>
        {themeOptions.map(
          ({ value, label, icon: Icon }) => (
            <button
              key={value}
              onClick={() => changeTheme(value as any)}
              className={`
              flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium
              transition-all duration-200
              ${
                theme === value
                  ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
                  : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
              }
            `}
            >
              <Icon className='w-4 h-4' />
              <span className='hidden sm:inline'>
                {label}
              </span>
            </button>
          )
        )}
      </div>
    </div>
  );
};

export default ThemeToggle;

より高度な実装として、テーマの変更をアニメーション付きで行うコンポーネントも作成できます。

typescript// components/AnimatedThemeToggle.tsx
import { useState } from 'react';
import { useTheme } from '../hooks/useTheme';

const AnimatedThemeToggle: React.FC = () => {
  const { isDark, toggleTheme } = useTheme();
  const [isAnimating, setIsAnimating] = useState(false);

  const handleToggle = () => {
    setIsAnimating(true);

    // アニメーションの準備
    document.documentElement.style.transition = 'none';

    // スクリーンショットを取得してオーバーレイとして表示する高度な実装も可能
    // ここでは簡単なフェード効果を実装
    const overlay = document.createElement('div');
    overlay.style.position = 'fixed';
    overlay.style.top = '0';
    overlay.style.left = '0';
    overlay.style.width = '100%';
    overlay.style.height = '100%';
    overlay.style.backgroundColor = isDark
      ? '#ffffff'
      : '#000000';
    overlay.style.opacity = '0';
    overlay.style.transition = 'opacity 0.3s ease-in-out';
    overlay.style.pointerEvents = 'none';
    overlay.style.zIndex = '9999';

    document.body.appendChild(overlay);

    // フェードイン
    requestAnimationFrame(() => {
      overlay.style.opacity = '0.1';
    });

    setTimeout(() => {
      toggleTheme();

      // フェードアウト
      overlay.style.opacity = '0';

      setTimeout(() => {
        document.body.removeChild(overlay);
        document.documentElement.style.transition = '';
        setIsAnimating(false);
      }, 300);
    }, 150);
  };

  return (
    <button
      onClick={handleToggle}
      disabled={isAnimating}
      className={`
        relative p-2 rounded-lg transition-all duration-200
        ${
          isDark
            ? 'bg-gray-800 text-yellow-400 hover:bg-gray-700'
            : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
        }
        ${
          isAnimating ? 'opacity-50 cursor-not-allowed' : ''
        }
      `}
      aria-label='テーマを切り替え'
    >
      <div
        className={`transform transition-transform duration-300 ${
          isDark ? 'rotate-180' : 'rotate-0'
        }`}
      >
        {isDark ? (
          <svg
            className='w-5 h-5'
            fill='currentColor'
            viewBox='0 0 20 20'
          >
            <path
              fillRule='evenodd'
              d='M10 2a8 8 0 100 16 8 8 0 000-16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z'
              clipRule='evenodd'
            />
          </svg>
        ) : (
          <svg
            className='w-5 h-5'
            fill='currentColor'
            viewBox='0 0 20 20'
          >
            <path
              fillRule='evenodd'
              d='M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662V11a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 9.766 14 8.991 14 8c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 5.092V5z'
              clipRule='evenodd'
            />
          </svg>
        )}
      </div>
    </button>
  );
};

export default AnimatedThemeToggle;

React Context を使用して、アプリケーション全体でテーマ状態を管理することも重要です。

typescript// contexts/ThemeContext.tsx
import React, {
  createContext,
  useContext,
  ReactNode,
} from 'react';
import { useTheme } from '../hooks/useTheme';

interface ThemeContextType {
  theme: 'light' | 'dark' | 'system';
  isDark: boolean;
  changeTheme: (theme: 'light' | 'dark' | 'system') => void;
  toggleTheme: () => void;
}

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

export const ThemeProvider: React.FC<{
  children: ReactNode;
}> = ({ children }) => {
  const themeHook = useTheme();

  return (
    <ThemeContext.Provider value={themeHook}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useThemeContext = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error(
      'useThemeContext must be used within a ThemeProvider'
    );
  }
  return context;
};

これにより、アプリケーションのどこからでも一貫したテーマ管理が可能になり、ユーザーの設定が確実に保存・復元されるようになります。

よくある問題と解決策(FOUC 対策、初期表示制御)

ダークモード実装において、多くの開発者が直面する問題の一つが FOUC(Flash of Unstyled Content)です。これは、ページの読み込み時に一瞬だけ間違ったテーマ(通常はライトモード)が表示される現象で、ユーザー体験を大きく損なう可能性があります。

FOUC を防ぐための最も効果的な方法は、HTML の <head> セクションにインラインスクリプトを配置し、JavaScript の実行を待たずにテーマクラスを適用することです。

html<!-- public/index.html または pages/_document.tsx -->
<script>
  (function () {
    function getInitialTheme() {
      const savedTheme = localStorage.getItem('theme');
      if (
        savedTheme &&
        ['light', 'dark'].includes(savedTheme)
      ) {
        return savedTheme;
      }

      if (
        window.matchMedia &&
        window.matchMedia('(prefers-color-scheme: dark)')
          .matches
      ) {
        return 'dark';
      }

      return 'light';
    }

    const theme = getInitialTheme();
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }

    // テーマ変更の検出
    try {
      localStorage.setItem('__theme-initialized', 'true');
    } catch (e) {
      // localStorage が利用できない場合の処理
    }
  })();
</script>

Next.js を使用している場合、カスタム _document.tsx を作成してより洗練されたアプローチを取ることができます。

typescript// pages/_document.tsx
import {
  Html,
  Head,
  Main,
  NextScript,
} from 'next/document';

export default function Document() {
  return (
    <Html lang='ja'>
      <Head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                function getInitialTheme() {
                  try {
                    const savedTheme = localStorage.getItem('theme');
                    if (savedTheme && ['light', 'dark'].includes(savedTheme)) {
                      return savedTheme;
                    }
                  } catch (e) {
                    console.warn('localStorage is not available');
                  }
                  
                  if (typeof window !== 'undefined' && window.matchMedia) {
                    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
                      return 'dark';
                    }
                  }
                  
                  return 'light';
                }

                const theme = getInitialTheme();
                const root = document.documentElement;
                
                if (theme === 'dark') {
                  root.classList.add('dark');
                } else {
                  root.classList.remove('dark');
                }
                
                // カスタムプロパティによる追加制御
                root.style.setProperty('--initial-theme', theme);
              })();
            `,
          }}
        />
      </Head>
      <body className='bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300'>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

サーバーサイドレンダリング(SSR)環境では、初期レンダリング時にクライアントとサーバーの状態が一致しない問題も発生します。この問題を解決するために、初期マウント時の処理を慎重に管理する必要があります。

typescript// hooks/useThemeSSR.ts
import { useState, useEffect } from 'react';

export const useThemeSSR = () => {
  const [mounted, setMounted] = useState(false);
  const [theme, setTheme] = useState<'light' | 'dark'>(
    'light'
  );

  useEffect(() => {
    setMounted(true);

    // マウント後にテーマを確認
    const root = document.documentElement;
    const hasInitialTheme = root.style.getPropertyValue(
      '--initial-theme'
    );
    const isDark = root.classList.contains('dark');

    setTheme(isDark ? 'dark' : 'light');
  }, []);

  const toggleTheme = () => {
    if (!mounted) return;

    const newTheme = theme === 'dark' ? 'light' : 'dark';
    setTheme(newTheme);

    const root = document.documentElement;
    if (newTheme === 'dark') {
      root.classList.add('dark');
    } else {
      root.classList.remove('dark');
    }

    try {
      localStorage.setItem('theme', newTheme);
    } catch (e) {
      console.warn('Could not save theme to localStorage');
    }
  };

  // SSR中は常にライトモードとして扱う
  return {
    theme: mounted ? theme : 'light',
    isDark: mounted ? theme === 'dark' : false,
    toggleTheme,
    mounted,
  };
};

画像やアイコンのダークモード対応も重要な課題です。特に、ロゴや装飾的な要素は、テーマに応じて適切なバリアントを表示する必要があります。

typescript// components/ResponsiveImage.tsx
interface ResponsiveImageProps {
  lightSrc: string;
  darkSrc: string;
  alt: string;
  className?: string;
}

const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
  lightSrc,
  darkSrc,
  alt,
  className = '',
}) => {
  const { mounted, isDark } = useThemeSSR();

  // SSR中は軽量なプレースホルダーを表示
  if (!mounted) {
    return (
      <div
        className={`bg-gray-200 animate-pulse ${className}`}
      />
    );
  }

  return (
    <img
      src={isDark ? darkSrc : lightSrc}
      alt={alt}
      className={`transition-opacity duration-300 ${className}`}
      loading='lazy'
    />
  );
};

// より高度な実装:picture要素を使用
const AdvancedResponsiveImage: React.FC<
  ResponsiveImageProps
> = ({ lightSrc, darkSrc, alt, className = '' }) => {
  return (
    <picture className={className}>
      <source
        srcSet={darkSrc}
        media='(prefers-color-scheme: dark)'
      />
      <source
        srcSet={lightSrc}
        media='(prefers-color-scheme: light)'
      />
      <img src={lightSrc} alt={alt} />
    </picture>
  );
};

export { ResponsiveImage, AdvancedResponsiveImage };

パフォーマンスの観点から、テーマ切り替え時の再レンダリングを最小化することも重要です。React の useMemouseCallback を活用して、不要な再計算を避けましょう。

typescript// components/OptimizedThemeComponent.tsx
import { useMemo, useCallback } from 'react';
import { useThemeContext } from '../contexts/ThemeContext';

const OptimizedThemeComponent: React.FC = () => {
  const { isDark, toggleTheme } = useThemeContext();

  // テーマに依存するスタイルをメモ化
  const themeStyles = useMemo(
    () => ({
      container: `
      min-h-screen transition-colors duration-300
      ${
        isDark
          ? 'bg-gray-900 text-gray-100'
          : 'bg-white text-gray-900'
      }
    `,
      card: `
      p-6 rounded-lg shadow-lg
      ${
        isDark
          ? 'bg-gray-800 border-gray-700'
          : 'bg-white border-gray-200'
      }
    `,
    }),
    [isDark]
  );

  // イベントハンドラーをメモ化
  const handleThemeToggle = useCallback(() => {
    toggleTheme();
  }, [toggleTheme]);

  return (
    <div className={themeStyles.container}>
      <div className={themeStyles.card}>
        <button onClick={handleThemeToggle}>
          テーマ切り替え
        </button>
      </div>
    </div>
  );
};

export default OptimizedThemeComponent;

これらの対策により、FOUC を防ぎ、快適なユーザー体験を提供するダークモード実装が可能になります。

まとめ:実用的で美しいダークモード実装の実現

本記事では、Tailwind CSS を使用したダークモード実装の包括的な手法について解説しました。基礎知識から実践的な実装手順、よくある問題の解決策まで、実際の開発現場で役立つ情報をお届けできたのではないでしょうか。

Tailwind CSS のダークモード機能は、単純なクラスの切り替えから始まり、高度なアニメーション効果や状態管理まで、幅広いニーズに対応できる柔軟性を持っています。dark: バリアントを効果的に活用することで、保守性が高く、一貫したデザインシステムを構築することが可能です。

JavaScript との連携においては、ユーザーの設定保存、システム設定との同期、FOUC の防止など、技術的な課題に対する具体的な解決策を提示しました。これらの手法を組み合わせることで、プロフェッショナルなレベルのダークモード対応を実現できるでしょう。

重要なのは、ダークモードは単なる見た目の変更ではなく、ユーザビリティとアクセシビリティの向上を目的とした機能であるという点です。適切なコントラスト比の確保、読みやすさの維持、パフォーマンスの最適化など、技術的な側面と人間工学的な側面の両方を考慮することが、成功するダークモード実装の鍵となります。

現代のウェブ開発において、ダークモード対応は必須の機能となりつつあります。本記事で紹介した手法を参考に、ユーザーに愛される美しく機能的なダークモード実装に挑戦していただければと思います。Tailwind CSS の力を借りて、あなたのプロジェクトをより魅力的で使いやすいものに進化させてください。

関連リンク

ダークモード実装や Tailwind CSS の学習をさらに深めるために、以下のリソースをご活用ください。