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>
  );
}

suppressHydrationWarninghtml 요소에만 — 서버/클라 속성 차이 경고 무시.


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-shadowsemantic 토큰화해야 한다.


요약

  • 다크모드 = 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를 매번 정의하지 않고 공식으로 파생하는 법.