본문 바로가기

React

React 앱에서 Dark Mode 토글 기능 구현하기

다크 모드는 브랜드 일관성, 접근성, 배터리 절약에 도움이 됩니다. 실무에서는 CSS 변수와 html의 dark 클래스 조합이 가장 단순하고 확장성이 좋습니다. 시스템 선호 감지, 로컬 저장, 초기 플래시 방지까지 한 번에 구현해보겠습니다.

1. 전략 요약

CSS 변수로 색상을 정의하고 html 요소에 dark 클래스를 토글합니다. 사용자의 선택은 localStorage에 저장하고, 선택이 없을 때만 시스템 선호(prefers-color-scheme)를 따릅니다. 초기 로드 시 스크립트로 dark 클래스를 먼저 붙여 FOUC(깜빡임)를 방지합니다.

2. CSS 토큰 준비

/* index.css 등 */
:root {
  --bg: #ffffff;
  --fg: #0f172a;
  color-scheme: light;
}
.dark {
  --bg: #0b1220;
  --fg: #e2e8f0;
  color-scheme: dark;
}
html, body {
  background: var(--bg);
  color: var(--fg);
}
body { transition: background-color .2s ease, color .2s ease; }
@media (prefers-reduced-motion: reduce) {
  body { transition: none; }
}

color-scheme를 설정하면 시스템 스크롤바, 폼 컨트롤 등도 모드에 맞게 그려집니다.

3. 초기 플래시(FOUC) 방지

React가 마운트되기 전에 html에 dark 클래스를 먼저 추가합니다. index.html의 최상단에 인라인 스크립트를 넣습니다.

/* public/index.html <head> 아래 혹은 <body> 최상단 */
(function() {
  try {
    var stored = localStorage.getItem('theme');
    var systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    var theme = (stored === 'light' || stored === 'dark') ? stored : (systemDark ? 'dark' : 'light');
    if (theme === 'dark') document.documentElement.classList.add('dark');
  } catch (e) {}
})();

4. React 훅: 상태, 동기화, 시스템 선호

import * as React from 'react';

const getPreferredTheme = () => {
  if (typeof window === 'undefined') return 'light';
  const stored = localStorage.getItem('theme');
  if (stored === 'light' || stored === 'dark') return stored;
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};

export function useTheme() {
  const [theme, setTheme] = React.useState(getPreferredTheme);

  // html.dark 토글 + 저장
  React.useEffect(() => {
    const root = document.documentElement;
    const isDark = theme === 'dark';
    root.classList.toggle('dark', isDark);
    localStorage.setItem('theme', isDark ? 'dark' : 'light');
  }, [theme]);

  // 시스템 선호 변화 반영 (사용자가 직접 선택하지 않은 경우만)
  React.useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)');
    const onChange = e => {
      const hasExplicit = localStorage.getItem('theme');
      if (!hasExplicit) setTheme(e.matches ? 'dark' : 'light');
    };
    if (mq.addEventListener) {
      mq.addEventListener('change', onChange);
      return () => mq.removeEventListener('change', onChange);
    } else {
      mq.addListener(onChange);
      return () => mq.removeListener(onChange);
    }
  }, []);

  const toggle = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'));
  const resetToSystem = () => {
    localStorage.removeItem('theme');
    setTheme(getPreferredTheme());
  };

  return { theme, toggle, resetToSystem };
}

5. 토글 버튼 컴포넌트

import React from 'react';
import { useTheme } from './useTheme';

export function ThemeToggle() {
  const { theme, toggle, resetToSystem } = useTheme();
  const isDark = theme === 'dark';
  return (
    <div>
      <button
        type='button'
        onClick={toggle}
        aria-pressed={isDark}
        title={isDark ? 'Switch to Light mode' : 'Switch to Dark mode'}
        style={{ padding: 8, borderRadius: 6 }}
      >
        {isDark ? '🌙 Dark' : '☀️ Light'}
      </button>
      <button type='button' onClick={resetToSystem} style={{ marginLeft: 8 }}>
        System
      </button>
    </div>
  );
}

aria-pressed로 상태를 전달하고 title로 힌트를 제공합니다.

6. 컴포넌트에서 토큰 사용 예시

function Card({ children }) {
  return (
    <div style={{
      background: 'var(--bg)',
      color: 'var(--fg)',
      border: '1px solid rgba(148, 163, 184, .2)',
      borderRadius: 8,
      padding: 16
    }}>
      {children}
    </div>
  );
}

7. Tailwind를 쓴다면

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  content: ['./src/**/*.{js,jsx,ts,tsx,html}'],
  theme: { extend: {} },
  plugins: []
};
export function Header() {
  return (
    <header className='bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100'>
      <h1 className='p-4'>App</h1>
    </header>
  );
}

위 훅이 html.dark를 관리하므로 Tailwind의 dark 변형이 자동으로 적용됩니다.

8. Next.js/SSR 주의

_document에서 초기 스크립트를 주입해 플래시를 막습니다.

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

export default class MyDocument extends Document {
  render() {
    const noFlash = `(function(){try{var s=localStorage.getItem('theme');var d=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches;var t=(s==='light'||s==='dark')?s:(d?'dark':'light');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}})();`;
    return (
      <Html>
        <Head />
        <body>
          <script dangerouslySetInnerHTML={{ __html: noFlash }} />
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

9. 빠른 테스트

import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ThemeToggle } from './ThemeToggle';

test('toggles html.dark', () => {
  render(<ThemeToggle />);
  const btn = screen.getByRole('button', { name: /light|dark/i });
  fireEvent.click(btn);
  expect(document.documentElement).toHaveClass('dark');
  fireEvent.click(btn);
  expect(document.documentElement).not.toHaveClass('dark');
});

10. 실무 체크리스트

- 토글 상태는 html에만 반영하고 컴포넌트는 CSS 변수 사용으로 단순화합니다.

- 사용자가 명시적으로 바꾸면 localStorage 키가 존재하므로 시스템 변경 이벤트는 무시합니다.

- Safari 등 브라우저 호환성을 위해 color-scheme을 함께 설정합니다.

- 전환 애니메이션은 prefers-reduced-motion을 존중합니다.

- 테마 색상은 디자인 토큰(변수)로만 관리해 확장에 대비합니다.

위 패턴은 의존성 없이 가볍고, Tailwind, Next.js 등과도 자연스럽게 결합합니다. 작은 훅 하나로 앱 전반의 다크 모드를 안정적으로 제어할 수 있습니다.