04 — Dark Mode Token Strategy
이 문서가 답하는 질문: 다크모드를 값 교체가 아니라 의미 재바인딩으로 푸는 방법은? 토큰 이름은 그대로 두고 OKLCH 값만 자동 전환되게 하려면 어떻게 설계해야 하나? 한 줄 답 (Pyramid Top): 다크모드는 “semantic 토큰의 이름은 그대로, primitive 토큰만 교체” 하는 2계층 매핑으로 푼다.
--color-bg-surface라는 이름은 light에서--gray-1(거의 흰색)을, dark에서--gray-1-dark(거의 검정)을 가리키도록 CSS variables의 cascade를 활용. 컴포넌트는 semantic 이름만 알면 된다.
Why — 왜 “값 교체”가 아니라 “이름 재바인딩”인가
값 교체 패턴의 한계
:root {
--primary: #3370b8;
}
[data-theme="dark"] {
--primary: #6ba3e3; /* 다크에선 더 밝은 색 */
}이 패턴은 작은 시스템에선 동작하지만, 다음 문제로 깨진다:
| 문제 | 증상 |
|---|---|
| 이름 의미 불일치 | --primary가 light에서 step 9, dark에서 step 8 → step 의미 어긋남 |
| scale 전체 관리 부담 | gray 12개 + blue 12개 + … → 다크 버전 모두 손수 정의 |
| 컴포넌트가 theme을 인지해야 함 | ”dark 모드일 땐 700 대신 300 써” 같은 분기 |
| 여러 brand × theme 폭발 | 3개 brand × 2개 theme = 6배 |
Semantic 토큰의 2계층 매핑
핵심: 컴포넌트는 semantic 이름만 안다. 어느 primitive를 가리키는지는 theme 컨텍스트에 의해 자동 결정된다.
How — 3단계 토큰 계층
1단계: Primitive (light + dark 둘 다 정의)
:root {
/* light primitives */
--gray-1: oklch(0.99 0 0);
--gray-2: oklch(0.98 0 0);
--gray-3: oklch(0.96 0 0);
/* ... */
--gray-12: oklch(0.21 0 0);
--blue-1: oklch(0.99 0.002 254);
--blue-9: oklch(0.55 0.23 254);
--blue-12: oklch(0.25 0.09 254);
/* dark primitives — 같은 이름의 -dark 버전 */
--gray-1-dark: oklch(0.14 0 0); /* 다크의 app bg */
--gray-2-dark: oklch(0.17 0 0);
--gray-3-dark: oklch(0.20 0 0);
/* ... */
--gray-12-dark: oklch(0.94 0 0); /* 다크의 high contrast text */
--blue-9-dark: oklch(0.60 0.20 254);
--blue-12-dark: oklch(0.88 0.10 254);
}다크 primitive는 명도 반전 — step 1이 가장 어둡고 step 12가 가장 밝다. step 9 (brand)는 L을 살짝만 올림 (다크 배경에서 brand가 너무 어두우면 안 보임).
2단계: Semantic (theme별로 재바인딩만)
:root {
/* default: light */
--color-bg-page: var(--gray-1);
--color-bg-surface: var(--gray-2);
--color-bg-component: var(--gray-3);
--color-bg-hover: var(--gray-4);
--color-bg-active: var(--gray-5);
--color-border-subtle: var(--gray-6);
--color-border-default: var(--gray-7);
--color-border-hover: var(--gray-8);
--color-text-primary: var(--gray-12);
--color-text-secondary: var(--gray-11);
--color-bg-brand-solid: var(--blue-9);
--color-bg-brand-hover: var(--blue-10);
--color-text-on-brand: white;
--color-text-link: var(--blue-11);
}
[data-theme="dark"] {
/* 같은 semantic 이름, 다른 primitive */
--color-bg-page: var(--gray-1-dark);
--color-bg-surface: var(--gray-2-dark);
--color-bg-component: var(--gray-3-dark);
--color-bg-hover: var(--gray-4-dark);
--color-bg-active: var(--gray-5-dark);
--color-border-subtle: var(--gray-6-dark);
--color-border-default: var(--gray-7-dark);
--color-border-hover: var(--gray-8-dark);
--color-text-primary: var(--gray-12-dark);
--color-text-secondary: var(--gray-11-dark);
--color-bg-brand-solid: var(--blue-9-dark);
--color-bg-brand-hover: var(--blue-10-dark);
--color-text-on-brand: white;
--color-text-link: var(--blue-11-dark);
}Semantic 이름은 동일. Primitive 매핑만 다름.
3단계: Component (semantic만 참조)
.btn-primary {
background: var(--color-bg-brand-solid);
color: var(--color-text-on-brand);
}
.btn-primary:hover {
background: var(--color-bg-brand-hover);
}
.card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
color: var(--color-text-primary);
}컴포넌트 CSS에 :root도, [data-theme]도, hex 값도 없다. 컴포넌트는 theme이 무엇인지 모른다.
What — 전환 메커니즘 4가지
1) data-theme 속성 (가장 흔함)
<html data-theme="dark">
<body>...</body>
</html>// 사용자 선택 저장 + 적용
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem('theme', theme);
}
// 페이지 로드 시 (FOUC 방지 — head에서 sync로)
(function() {
const saved = localStorage.getItem('theme');
const system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = saved || system;
})();data-theme="dark"는 명시적이고 JavaScript로 토글 가능. 사용자 설정 우선.
2) prefers-color-scheme 미디어 쿼리
:root {
--color-bg-page: var(--gray-1);
/* ... light defaults */
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg-page: var(--gray-1-dark);
/* ... dark overrides */
}
}OS 설정을 자동 반영. 사용자 명시 설정이 없을 때만.
3) 둘을 결합 — “system + manual override”
/* default: system */
:root {
--color-bg-page: var(--gray-1);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg-page: var(--gray-1-dark);
}
}
/* manual override가 더 specific */
[data-theme="light"] {
--color-bg-page: var(--gray-1);
}
[data-theme="dark"] {
--color-bg-page: var(--gray-1-dark);
}data-theme 속성이 cascade winner. 없으면 prefers-color-scheme로 떨어진다.
4) color-scheme CSS 속성 — 브라우저 UI 까지 전환
:root {
color-scheme: light dark; /* 둘 다 지원 선언 */
}
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }이게 결정적이다 — color-scheme: dark를 선언하면:
- 브라우저 스크롤바가 다크 톤
- form control (input, select 등의 기본 스타일)이 다크
- iframe 안의 페이지도 다크 힌트 받음
<meta name="theme-color">와 동기화
<meta name="theme-color" content="#0f1419" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">theme-color는 모바일 브라우저 주소창 색까지 전환. 디자인 시스템의 마지막 한 픽셀.
What — FOUC 방지 + Hydration 안전
문제: 서버 렌더링과 클라이언트 theme의 불일치
Next.js·SvelteKit·Astro에서 서버는 사용자 OS 설정을 모름 → SSR HTML이 light인데 클라이언트가 dark로 토글 → 깜빡임 (FOUC).
해결: head에서 script로 즉시 적용
<head>
{/* 이게 head 가장 위에 있어야 함 */}
<script>
(function() {
try {
var saved = localStorage.getItem('theme');
var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var theme = saved || system;
document.documentElement.dataset.theme = theme;
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="/app.css" />
</head>- synchronous 실행 — DOM이 paint 되기 전에 속성이 박힘.
try/catch는 localStorage 접근 실패 (private mode, disabled) 대비.dataset.theme또는documentElement.style.setProperty.
Next.js 패턴 — next-themes 라이브러리가 이걸 캡슐화.
// app/layout.tsx
import { ThemeProvider } from 'next-themes';
export default function Root({ children }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}suppressHydrationWarning은 html 요소에만 — 서버/클라 속성 차이 경고 무시.
What — 전체 통합 예시
디자인 시스템 CSS
/* tokens/primitives.css */
:root {
/* Gray — light */
--gray-1: oklch(0.994 0 0);
--gray-2: oklch(0.982 0 0);
--gray-3: oklch(0.961 0 0);
--gray-4: oklch(0.938 0 0);
--gray-5: oklch(0.907 0 0);
--gray-6: oklch(0.869 0 0);
--gray-7: oklch(0.819 0 0);
--gray-8: oklch(0.741 0 0);
--gray-9: oklch(0.553 0 0);
--gray-10: oklch(0.516 0 0);
--gray-11: oklch(0.482 0 0);
--gray-12: oklch(0.249 0 0);
/* Gray — dark (명도 반전 + chroma 조정) */
--gray-1-dark: oklch(0.142 0 0);
--gray-2-dark: oklch(0.180 0 0);
--gray-3-dark: oklch(0.213 0 0);
--gray-4-dark: oklch(0.243 0 0);
--gray-5-dark: oklch(0.281 0 0);
--gray-6-dark: oklch(0.331 0 0);
--gray-7-dark: oklch(0.401 0 0);
--gray-8-dark: oklch(0.529 0 0);
--gray-9-dark: oklch(0.557 0 0);
--gray-10-dark: oklch(0.604 0 0);
--gray-11-dark: oklch(0.706 0 0);
--gray-12-dark: oklch(0.941 0 0);
/* Blue — light */
--blue-9: oklch(0.553 0.234 247.85);
--blue-10: oklch(0.516 0.232 247.85);
--blue-11: oklch(0.482 0.180 247.85);
--blue-12: oklch(0.249 0.085 247.85);
/* Blue — dark */
--blue-9-dark: oklch(0.609 0.205 247.85);
--blue-10-dark: oklch(0.659 0.180 247.85);
--blue-11-dark: oklch(0.760 0.140 247.85);
--blue-12-dark: oklch(0.911 0.060 247.85);
}/* tokens/semantic.css */
:root {
color-scheme: light;
--color-bg-page: var(--gray-1);
--color-bg-surface: var(--gray-2);
--color-bg-component: var(--gray-3);
--color-bg-hover: var(--gray-4);
--color-bg-active: var(--gray-5);
--color-border-subtle: var(--gray-6);
--color-border-default: var(--gray-7);
--color-text-primary: var(--gray-12);
--color-text-secondary: var(--gray-11);
--color-bg-brand-solid: var(--blue-9);
--color-bg-brand-hover: var(--blue-10);
--color-text-link: var(--blue-11);
--color-text-on-brand: white;
}
[data-theme="dark"] {
color-scheme: dark;
--color-bg-page: var(--gray-1-dark);
--color-bg-surface: var(--gray-2-dark);
--color-bg-component: var(--gray-3-dark);
--color-bg-hover: var(--gray-4-dark);
--color-bg-active: var(--gray-5-dark);
--color-border-subtle: var(--gray-6-dark);
--color-border-default: var(--gray-7-dark);
--color-text-primary: var(--gray-12-dark);
--color-text-secondary: var(--gray-11-dark);
--color-bg-brand-solid: var(--blue-9-dark);
--color-bg-brand-hover: var(--blue-10-dark);
--color-text-link: var(--blue-11-dark);
--color-text-on-brand: white;
}부드러운 전환 (선택)
:root {
transition: background-color 0.2s ease, color 0.2s ease;
}단, 너무 많은 곳에 transition을 걸면 theme 전환 시 잔상 또는 성능 저하. 보통 body / html에만, 컴포넌트는 background-color만 짧게.
What-if — 잘못 다루면
1) :root에 dark도 박기
/* 안 됨 */
:root {
--color-bg-page: white;
background: var(--color-bg-page); /* 항상 white */
}
@media (prefers-color-scheme: dark) {
:root {
background: black; /* 토큰 무시하고 직접 색 */
}
}토큰을 우회하면 시스템이 깨진다. 항상 semantic 토큰만 컴포넌트에서 참조.
2) color-scheme 빼먹기
[data-theme="dark"] {
--color-bg-page: var(--gray-1-dark);
/* color-scheme: dark; ← 빠짐 */
}→ 스크롤바·input·select가 light 톤 그대로. 페이지는 다크인데 form 컨트롤만 light → 시각적 불일치.
3) FOUC 방치
서버 HTML이 light인데 클라이언트 JS가 dark로 토글 → 100~300ms 동안 white flash. head의 sync script가 필수.
4) 다크 모드에서도 light contrast 보장한다고 가정
light에서 검증한 pair가 dark에서도 통과한다는 보장 없음. 모든 pair를 theme별로 재검증. → 03장
5) 3개 이상 theme을 [data-theme="X"] n중첩으로
[data-theme="dark"] [data-brand="premium"] {
/* 이런 식의 cascading은 빠르게 폭발 */
}theme 차원이 여러 개면 멀티-속성 + CSS custom property scoping으로 분리하는 게 낫다.
[data-theme="dark"] { /* brand-agnostic dark values */ }
[data-brand="premium"] { /* theme-agnostic brand overrides */ }→ 06장 theming 챕터에서 자세히.
Insight — 왜 “light가 기본” 패턴이 사실상 표준인가
“디폴트를 누구로 두는가 = 누구의 책임을 디폴트로 두는가”
2010년대 초만 해도 다크모드는 코드 에디터의 옵션이었다. 일반 웹은 light가 절대 기본이었다. 2018년 iOS 13·macOS Mojave가 시스템 다크 모드를 추가했고, 2019년 prefers-color-scheme이 CSS Color에 들어갔다.
흥미로운 점은 디자인 시스템의 다크모드 패턴은 거의 모두 light first다 — :root에 light, [data-theme="dark"]에 dark. 왜?
1) 인쇄 호환 — 페이지를 PDF로 출력하거나 종이에 인쇄하는 경우 light가 디폴트여야 함.
2) 디자이너 작업 방식 — Figma도 디폴트가 light. 디자이너가 light에서 디자인하고 dark는 파생하는 게 자연스러움.
3) Cascade 효율 — light primitive를 모든 토큰의 기본으로 두면, dark는 변경 부분만 override. 다크가 적은 override면 CSS 크기 절약.
4) 접근성 베이스라인 — WCAG 검증을 light에서 먼저 통과시키면 대부분의 pair가 dark에서도 만족하는 경향 (반대로는 잘 안 됨).
5년 전엔 “다크 우선” 시스템(Github 같은) 시도가 있었지만 현재는 거의 light first로 수렴. 이게 사실상 표준.
또 하나의 반전 — 진정한 다크모드는 명도만 반전하는 게 아니라 그림자·glow·boundary의 시각 언어가 다르다. light에선 그림자로 깊이, dark에선 밝은 boundary로 깊이. 단순 OKLCH L 반전만으로는 그림자가 dark에서 안 보임 → box-shadow도 semantic 토큰화해야 한다.
요약
- 다크모드 = Primitive 토큰 교체 + Semantic 토큰 이름 유지.
:root(light) +[data-theme="dark"](dark) 2-state cascade.color-scheme: light dark선언 +prefers-color-scheme미디어 쿼리.head의 sync script로 FOUC 방지.next-themes같은 라이브러리가 이 패턴 캡슐화.- 컴포넌트는 theme을 모른다 — semantic 토큰만 참조.
- 모든 contrast pair를 theme별로 재검증.
다음: 05-color-mix-and-dynamic-themes — primitive를 매번 정의하지 않고 공식으로 파생하는 법.