다크 모드를 "대충" 구현하는 건 쉽다. prefers-color-scheme 미디어 쿼리 하나면 된다. 문제는 "제대로" 구현하려고 할 때 시작된다. 사용자가 수동으로 전환할 수 있어야 하고, 새로고침해도 유지돼야 하고, 전환할 때 깜빡이면 안 되고, 시스템 설정도 존중해야 한다.
이 블로그의 다크 모드를 구현하면서 겪은 과정을 정리했다.
CSS 변수로 테마 정의
먼저 색상을 CSS 변수로 관리한다. 컴포넌트마다 색상을 하드코딩하면 나중에 전부 바꿔야 하니까.
:root {
/* 라이트 모드 (기본) */
--color-bg: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-border: #e0e0e0;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-code-bg: #f1f5f9;
--color-code-text: #334155;
}
[data-theme='dark'] {
--color-bg: #0f0f0f;
--color-bg-secondary: #1a1a1a;
--color-text: #e0e0e0;
--color-text-secondary: #999999;
--color-border: #2a2a2a;
--color-primary: #60a5fa;
--color-primary-hover: #93bbfd;
--color-code-bg: #1e293b;
--color-code-text: #e2e8f0;
}[data-theme='dark'] 대신 .dark 클래스를 쓰는 방법도 있다. Tailwind CSS를 쓴다면 darkMode: 'class' 설정과 함께 .dark 클래스를 쓰는 게 자연스럽다.
컴포넌트에서는 이 변수를 참조한다.
.card {
background-color: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.button-primary {
background-color: var(--color-primary);
color: white;
}
.button-primary:hover {
background-color: var(--color-primary-hover);
}색상을 바꿀 때 CSS 변수 값만 바꾸면 모든 컴포넌트에 반영된다.
테마 전환 로직
테마 상태는 세 가지 경우를 고려해야 한다.
- 사용자가 명시적으로 선택한 테마가 있으면 그걸 따른다.
- 선택한 적 없으면 시스템 설정을 따른다.
- 시스템 설정이 바뀌면 (시스템 설정을 따르는 중일 때) 자동으로 반영한다.
// hooks/useTheme.ts
import { useState, useEffect, useCallback } from 'react';
type Theme = 'light' | 'dark';
type ThemePreference = Theme | 'system';
function getSystemTheme(): Theme {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
function getStoredPreference(): ThemePreference {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme-preference') as ThemePreference) || 'system';
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme);
}
export function useTheme() {
const [preference, setPreference] = useState<ThemePreference>('system');
const [resolvedTheme, setResolvedTheme] = useState<Theme>('light');
// 초기화
useEffect(() => {
const stored = getStoredPreference();
setPreference(stored);
const resolved = stored === 'system' ? getSystemTheme() : stored;
setResolvedTheme(resolved);
applyTheme(resolved);
}, []);
// 시스템 테마 변경 감지
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (preference === 'system') {
const newTheme = getSystemTheme();
setResolvedTheme(newTheme);
applyTheme(newTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [preference]);
const setTheme = useCallback((newPreference: ThemePreference) => {
setPreference(newPreference);
localStorage.setItem('theme-preference', newPreference);
const resolved = newPreference === 'system' ? getSystemTheme() : newPreference;
setResolvedTheme(resolved);
applyTheme(resolved);
}, []);
return { theme: resolvedTheme, preference, setTheme };
}
토글 버튼 UI는 이런 식이다.
function ThemeToggle() {
const { preference, setTheme } = useTheme();
const options: { value: ThemePreference; label: string }[] = [
{ value: 'light', label: '라이트' },
{ value: 'dark', label: '다크' },
{ value: 'system', label: '시스템' },
];
return (
<div className="theme-toggle">
{options.map(({ value, label }) => (
<button
key={value}
onClick={()=> setTheme(value)}
className={preference= value ? 'active' : ''}
>
{label}
</button>
))}
</div>
);
}라이트/다크 두 가지만 토글하는 것보다 "시스템" 옵션을 주는 게 좋다. macOS나 Windows에서 자동 다크 모드를 쓰는 사용자가 많으니까.
깜빡임(Flash) 방지
이게 다크 모드에서 가장 까다로운 부분이다. 사용자가 다크 모드를 선택했는데, 페이지를 새로고침하면 잠깐 라이트 모드가 번쩍 보이는 현상. FOUC(Flash of Unstyled Content)와 비슷한 문제다.
원인은 이렇다. HTML이 먼저 로드되고, CSS가 적용되고, 그 다음에 JavaScript가 실행돼서 테마를 설정한다. JavaScript가 실행되기 전까지는 기본 테마(라이트)가 보이는 거다.
해결 방법은 JavaScript가 실행되기 전에 테마를 적용하는 거다. <head> 안에 인라인 스크립트를 넣으면 된다.
Next.js App Router에서는 layout.tsx에 이렇게 넣는다.
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var preference= localStorage.getItem('theme-preference') || 'system';
var theme;
if (preference= 'system') {
theme= window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
theme= preference;
}
document.documentElement.setAttribute('data-theme', theme);
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}이 스크립트는 파싱 차단(render-blocking)이지만, 아주 짧아서 성능에 영향이 거의 없다. 그 대가로 깜빡임을 완전히 방지할 수 있다.
suppressHydrationWarning을 넣는 이유는 서버에서 렌더링한 data-theme과 클라이언트에서 설정한 data-theme이 다를 수 있어서다. 이 경고는 무시해도 안전하다.
전환 애니메이션
테마가 바뀔 때 부드럽게 전환하려면 transition을 건다.
:root {
--transition-theme: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
body,
.card,
.button,
.input {
transition: var(--transition-theme);
}주의할 점은 초기 로드 시에는 transition을 끄는 거다. 안 그러면 페이지가 처음 로드될 때 라이트에서 다크로 전환되는 애니메이션이 보인다.
// 초기 로드 시 transition 비활성화
useEffect(() => {
// 초기 렌더 후 약간의 딜레이를 주고 transition 활성화
const timer = setTimeout(() => {
document.documentElement.classList.add('theme-transitions-enabled');
}, 100);
return () => clearTimeout(timer);
}, []);/* transition은 클래스가 있을 때만 적용 */
.theme-transitions-enabled body,
.theme-transitions-enabled .card {
transition: var(--transition-theme);
}이미지 처리
다크 모드에서 놓치기 쉬운 게 이미지다. 밝은 배경의 이미지가 다크 모드에서 눈을 찌를 수 있다.
/* 다크 모드에서 이미지 밝기를 약간 낮추기 */
[data-theme='dark'] img:not([data-no-dim]) {
filter: brightness(0.9);
}
/* 로고처럼 배경이 투명한 이미지 반전 */
[data-theme='dark'] .invertible {
filter: invert(1) hue-rotate(180deg);
}모든 이미지에 일괄 적용하는 것보다, 필요한 이미지에만 클래스를 붙이는 게 안전하다.
next-themes 쓸까?
next-themes라는 라이브러리가 있다. 위에서 설명한 것들을 대부분 처리해준다.
// app/layout.tsx
import { ThemeProvider } from 'next-themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<body>
<ThemeProvider attribute="data-theme" defaultTheme="system">
{children}
</ThemeProvider>
</body>
</html>
);
}// 컴포넌트에서
import { useTheme } from 'next-themes';
function ThemeToggle() {
const { theme, setTheme } = useTheme();
// ...
}깜빡임 방지, 시스템 설정 감지, localStorage 저장을 전부 해준다. 직접 구현하는 것보다 이걸 쓰는 게 실용적이긴 하다. 다만 내부에서 어떤 일이 일어나는지 이해하고 쓰는 거랑 모르고 쓰는 건 다르다. 그래서 위에서 직접 구현하는 과정을 먼저 설명했다.
다크 모드를 구현하다 보면 "색상"만 바꾸는 게 아니라는 걸 알게 된다. 그림자의 강도, 이미지의 밝기, 보더의 가시성, 텍스트의 대비. 라이트와 다크에서 같은 값이 같은 느낌을 주지 않는다. 디자이너 없이 혼자 색상을 정하면 십중팔구 다크 모드의 대비가 너무 강하거나 너무 약하게 나온다. 피그마에서 라이트/다크 모두 검수받는 게 맞고, 그게 안 되면 실제 기기에서 밤에 직접 써보면서 조정하는 수밖에 없다.
