02 — Dark Mode Strategy
이 문서가 답하는 질문: 다크모드를 켜는 신호는 세 가지 —
prefers-color-scheme미디어 쿼리,[data-theme="dark"]속성,.dark클래스 — 가 있다. 어떤 것을 골라야 하고, SSR 환경에서 첫 페인트 시 라이트가 깜빡이는 FOUC는 어떻게 막는가? 한 줄 답 (Pyramid Top): 정답은 “[data-theme]속성 + blocking inline<script>+color-schemeCSS property” 의 3-콤보다 —prefers-color-scheme만 쓰면 사용자 토글이 불가능하고,.dark클래스만 쓰면 시스템 자동 추적이 어렵다. 둘 다 하나의<html data-theme>로 수렴시키고, blocking script로 FOUC를 막고,color-scheme으로 브라우저 UI(스크롤바·form)까지 다크로 끌고 가는 것이 사실상 표준이다.
Why — 왜 존재하는가
다크모드는 2018년 macOS Mojave 출시 후 디자인 시스템의 필수 기능이 됐다. 하지만 “어떻게 검출하느냐”는 세 진영으로 갈렸다.
| 풀려는 문제 | 접근 | 한계 |
|---|---|---|
| 시스템 설정 자동 추적 | @media (prefers-color-scheme: dark) | 사용자가 앱 안에서 토글할 수 없음 |
| 사용자 토글 | localStorage → .dark 클래스 | SSR 첫 페인트에 라이트가 깜빡임 (FOUC) |
| 라이브러리 호환 (Tailwind) | .dark class selector | data-attribute 기반 다른 시스템과 충돌 |
| 브라우저 UI(스크롤바) | (해법 없음) | 다크 페이지에 흰 스크롤바 — 보기 흉함 |
2020년경 next-themes(Vercel 팀의 Paco Coursey)가 사실상 표준 패턴을 정리했다:
- 내부적으로는
[data-theme]속성으로 통일. - 사용자 선택 + 시스템 추적을 함께 지원 (
light/dark/system). - SSR 환경에서 첫 페인트 전에
data-theme을 박는 inline blocking script 주입. - CSS에
color-scheme: light dark;를 박아 브라우저 UI도 다크로.
이 패턴이 React/Next 진영에서 굳어졌고, Tailwind v4·Panda CSS 모두 이를 전제로 한다.
How — 어떻게 동작하는가
핵심은 <head> 안 inline <script> 가 body 렌더 전에 <html>에 data-theme을 박는 것. 이 script는:
- 외부 파일이 아니라 inline (네트워크 대기 없음)
defer/async없음 (parser-blocking이 맞는 선택)- 50~80 bytes 정도
{/* <head> 최상단 */}
<script>
(function () {
try {
var t = localStorage.getItem('theme');
var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var resolved = t === 'light' || t === 'dark' ? t : system;
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) {}
})();
</script>What — 구체 사양 / 수치 / 예시
세 가지 검출 방식 비교
| 방식 | 신호 | 사용자 토글 | SSR FOUC | Tailwind v3 호환 | Panda 호환 |
|---|---|---|---|---|---|
@media (prefers-color-scheme: dark) | OS 설정만 | 불가능 | 없음 (CSS) | darkMode: 'media' | _osDark |
.dark class | JS로 토글 | 가능 | 있음 — script 필요 | darkMode: 'class' (기본) | [class*=dark] & |
[data-theme] attr | JS로 토글 | 가능 | 있음 — script 필요 | darkMode: ['class', '[data-theme="dark"]'] | [data-theme=dark] & |
권장: [data-theme] + inline script + color-scheme property.
color-scheme CSS property — 자주 잊는 1줄
브라우저는 color-scheme을 보고 기본 form control, 스크롤바, focus ring 색을 결정한다.
:root { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
/* 또는 — 사용자가 OS 설정을 따르도록 */
:root { color-scheme: light dark; }이 한 줄이 없으면 다크 페이지에 흰 스크롤바, 흰 form control이 그대로 남는다.
Next.js + 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"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}attribute="data-theme"—.darkclass 대신 attribute 사용.suppressHydrationWarning— hydration 시 서버/클라이언트 attr 차이는 의도된 것임을 React에 알림.disableTransitionOnChange— 토글 순간 모든 transition 일시 정지 (전환 잔상 방지).
Vanilla 셋업 (프레임워크 없이)
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<script>
(function () {
var t = null;
try { t = localStorage.getItem('theme'); } catch (e) {}
var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute(
'data-theme',
t === 'light' || t === 'dark' ? t : system
);
})();
</script>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<button id="toggle">Toggle</button>
<script>
document.getElementById('toggle').addEventListener('click', () => {
var cur = document.documentElement.getAttribute('data-theme');
var next = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
</script>
</body>
</html>CSS 토큰 셋업
:root {
color-scheme: light;
--color-bg: #ffffff;
--color-fg: #18181b;
}
[data-theme="dark"] {
color-scheme: dark;
--color-bg: #0a0a0a;
--color-fg: #fafafa;
}
/* 사용자가 system을 골랐을 때 OS 설정 추적 */
@media (prefers-color-scheme: dark) {
[data-theme="system"] {
color-scheme: dark;
--color-bg: #0a0a0a;
--color-fg: #fafafa;
}
}
body {
background: var(--color-bg);
color: var(--color-fg);
}토글 시 transition 깜빡임 방지
.disable-transitions * {
transition: none !important;
}function setTheme(next: 'light' | 'dark') {
document.documentElement.classList.add('disable-transitions');
document.documentElement.setAttribute('data-theme', next);
// 한 프레임 뒤 transition 복구
requestAnimationFrame(() => {
document.documentElement.classList.remove('disable-transitions');
});
}What-if — 잘못 쓰면 어떻게 깨지는가
- 함정 1 — FOUC (Flash of Unstyled Content): blocking script를 안 박고 React가 mount된 뒤 테마를 설정. 결과: 첫 0.3초 라이트, 그 다음 다크로 깜빡임. Render-blocking inline script가 정답 — async/defer 절대 금지.
- 함정 2 —
color-scheme누락: 모든 컴포넌트는 다크인데 스크롤바·<input type="date">·focus ring만 흰색. CSS 한 줄(color-scheme: dark)이면 해결. - 함정 3 — 토글 시 모든 transition이 한꺼번에 발동: 1000개 요소가 색을 0.3초 전환 — 잔상·성능 저하. 토글 직전에
transition: none !important임시 부착. - 함정 4 —
localStorage접근 try/catch 없이: Safari private mode·iframe sandbox에서localStorage접근만으로 throw. 반드시 try/catch. - 함정 5 — Tailwind v3
darkMode: 'media'로만 쓰기: 사용자 토글 불가. v3는 반드시darkMode: 'class'또는['class', '[data-theme="dark"]']. - 함정 6 —
suppressHydrationWarning잊기: Next.js가 hydration mismatch 경고를 콘솔에 쏟아냄.<html>에 박는다. - 함정 7 — img/svg가 다크에서 그대로: 로고가 검은색이라 다크 배경에 안 보임.
<picture>+prefers-color-scheme미디어 source, 또는 SVG inline +currentColor.
<picture>
<source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
<img src="/logo-light.svg" alt="Logo" />
</picture>단,
<picture>는 시스템 설정만 추적한다. 사용자 토글에도 따라가게 하려면 JS로<img src>를 swap하거나, CSSbackground-image를 토큰으로 받는다.
Insight — 흥미로운 이야기
다크모드 FOUC를 처음 체계적으로 해결한 사람은 GitHub의 Mu-An Chiou다 — 2019년 GitHub에 다크모드를 도입할 때 inline blocking script 패턴을 표준화했고, 이후 next-themes가 이를 React 진영에 가져왔다.
흥미로운 디테일: GitHub은 단순 light/dark가 아니라 light / dark / dark_dimmed / dark_high_contrast / light_high_contrast / light_colorblind / dark_colorblind 7가지 테마를 같은 data-color-mode + data-light-theme + data-dark-theme 3개 attribute 조합으로 표현한다. 즉, data-theme 단일 값이 아니라 (현재 모드, 라이트일 때 테마, 다크일 때 테마) 세 축. 사용자는 “라이트는 colorblind 친화, 다크는 dimmed로”처럼 각 모드별로 테마를 고를 수 있다.
또 하나의 반전: Chrome의 “Force Dark Mode” 는 사이트가 color-scheme: light dark나 prefers-color-scheme을 명시적으로 지원한다고 선언하면 자동 변환을 건너뛴다. 즉, 다크모드를 제대로 만들면 브라우저의 어색한 자동 색 반전을 피할 수 있다. color-scheme 메타 정보가 “우리는 다크를 직접 처리한다”는 신호로 작동하는 셈.
마지막으로 — prefers-color-scheme은 사실 1996년 CSS2 초안의 media="screen, print" 의 후예다. CSS는 처음부터 사용자 환경에 맞춰 다르게 그릴 수 있는 매체로 설계됐고, “color-scheme”은 그 철학의 30년 만의 부활이다.
요약
- 다크모드 검출은 3가지 방법이 있지만 사실상
[data-theme]+ inline blocking script +color-schemeproperty 3-콤보가 표준. - FOUC는 render-blocking inline script로 막는다 — async/defer 금지.
color-schemeCSS property가 없으면 스크롤바·form control이 라이트로 남는다 — 한 줄이지만 자주 잊힘.- next-themes는 이 패턴을 React에 표준화했고, Tailwind v3는
darkMode: 'class', Panda는_darkcondition으로 같은 메커니즘을 표현한다.