다크 모드는 브랜드 일관성, 접근성, 배터리 절약에 도움이 됩니다. 실무에서는 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 등과도 자연스럽게 결합합니다. 작은 훅 하나로 앱 전반의 다크 모드를 안정적으로 제어할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 SVG 애니메이션 구현하기 (0) | 2026.04.24 |
|---|---|
| React Fiber 아키텍처 이해하기 (1) | 2026.04.24 |
| React에서 접근성을 고려한 UI 컴포넌트 설계 (0) | 2026.04.23 |
| React Native와 웹 React 코드 재사용 전략 (1) | 2026.04.23 |
| React로 드래그 앤 드롭 인터페이스 만들기 (0) | 2026.04.22 |