07 — Dark Mode & Theming

한 줄 답: 모던 다크모드는 3가지 결정으로 압축된다 — (1) 어떻게 감지할 것인가(prefers-color-scheme vs 사용자 토글), (2) 어떻게 전환할 것인가(color-scheme + 시맨틱 토큰), (3) 어디서 분기할 것인가(light-dark() vs :root[data-theme]). 2024년의 light-dark() 함수는 수백 줄의 분기를 한 줄로 줄였다.


Why — 다크모드는 왜 어려운가

단순해 보이지만 5가지 함정

.card { background: white; color: black; }
.card.dark { background: black; color: white; }

— 작동하지만 곧 다음 문제가 보인다:

  1. 수백 개 컴포넌트.dark 변형 → 유지보수 폭발.
  2. <select>·<input> 같은 폼 컨트롤은 OS가 그린다 → 다크모드에서 흰 배경으로 튐.
  3. 이미지·그림자는 다크 배경에서 다르게 보여야 함.
  4. 사용자 선호(OS 설정)와 수동 토글충돌 — 어느 게 이기나?
  5. SSR/SSG에서 첫 페인트에 깜빡임 (FOUC, Flash of Unstyled Content).

제대로 된 다크모드는 디자인 시스템 전체를 다시 본다.


How — 3가지 전략의 지도


What — 전략 1: System Only (가장 단순)

OS 다크모드 설정을 그대로 따른다. 사용자 토글 없음.

:root {
  --color-bg:   white;
  --color-text: black;
}
 
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg:   #0a0a0a;
    --color-text: #e5e5e5;
  }
}
 
body {
  background: var(--color-bg);
  color:      var(--color-text);
}

장점:

  • 코드가 짧다.
  • JS 0줄.
  • SSR FOUC 없음 (CSS 자체가 처리).

단점:

  • 사용자가 밝게/어둡게 토글할 수 없음.
  • 사이트 안에서 부분 라이트 같은 미세 조정 불가.

적합: 블로그, 문서 사이트, 작은 앱.


What — 전략 2: Manual Override (사실상 표준)

OS 설정을 기본값으로 두고, 사용자 토글로 덮어쓴다.

1) HTML 속성으로 분기

<html data-theme="auto">
  <!-- "auto" | "light" | "dark" -->
</html>
:root {
  --color-bg:   white;
  --color-text: black;
}
 
/* auto: OS 설정 따라감 */
:root[data-theme="auto"] {
  @media (prefers-color-scheme: dark) {
    --color-bg:   #0a0a0a;
    --color-text: #e5e5e5;
  }
}
 
/* dark: 강제 다크 */
:root[data-theme="dark"] {
  --color-bg:   #0a0a0a;
  --color-text: #e5e5e5;
}
 
/* light는 :root 기본값으로 충분 */

2) JS로 토글

function setTheme(theme) {
  document.documentElement.dataset.theme = theme;
  localStorage.setItem('theme', theme);
}
 
// 페이지 로드 시 (head에 인라인 권장 — FOUC 방지)
const saved = localStorage.getItem('theme') || 'auto';
document.documentElement.dataset.theme = saved;

FOUC 방지가 핵심: 위 JS를 <head>최우선 인라인 스크립트로. body 렌더 data-theme 설정.


What — color-scheme (OS 폼 컨트롤까지 다크화)

CSS 명세의 작지만 중요한 속성. OS가 그리는 UI까지 다크 모드로 전환.

:root { color-scheme: light dark; }
 
:root[data-theme="dark"]  { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }

효과:

  • <input>·<select>·<textarea> 기본 색상 자동 적응.
  • 스크롤바 색상 자동.
  • 폼 자동완성 배경색 자동.
  • <details> 토글 아이콘 색 자동.

color-scheme 없으면 → 다크 페이지에 흰 스크롤바·흰 폼 컨트롤이 튀어나옴.

<!-- 작은 한 줄로 큰 차이 -->
<meta name="color-scheme" content="light dark">

What — light-dark() 함수 (2024년 Baseline)

CSS Color Module Level 5의 새 함수. 두 값을 받아 현재 color-scheme에 맞는 쪽을 반환.

이전 (분기 6줄)

:root {
  --color-text: black;
  --color-bg:   white;
}
@media (prefers-color-scheme: dark) {
  :root {
    --color-text: #e5e5e5;
    --color-bg:   #0a0a0a;
  }
}

이후 (한 줄씩)

:root {
  color-scheme: light dark;
  --color-text: light-dark(black, #e5e5e5);
  --color-bg:   light-dark(white, #0a0a0a);
}

light-dark()는:

  • 현재 color-scheme을 보고 한쪽 선택.
  • :root[data-theme="dark"] { color-scheme: dark } 와 결합하면 수동 토글도 자동 작동.
  • 모든 색상 속성에서 사용 가능.

브라우저: Chrome 123+, Safari 17.5+, Firefox 120+ (Baseline 2024).


What — 토큰 시스템과의 통합

다크모드의 핵심은 semantic 토큰 층에서 일어난다 (01-design-tokens 참고).

:root {
  color-scheme: light dark;
 
  /* primitive (변하지 않음) */
  --gray-50:  #f9fafb;
  --gray-900: #111827;
  --blue-500: #2563eb;
  --blue-300: #93c5fd;
 
  /* semantic (다크모드에서 재매핑) */
  --color-bg:           light-dark(white,        var(--gray-900));
  --color-surface:      light-dark(var(--gray-50), #1f2937);
  --color-text:         light-dark(var(--gray-900), var(--gray-50));
  --color-text-muted:   light-dark(#6b7280, #9ca3af);
  --color-action:       light-dark(var(--blue-500), var(--blue-300));
  --color-border:       light-dark(#e5e7eb, #374151);
}
 
.card {
  background: var(--color-surface);
  color:      var(--color-text);
  border:     1px solid var(--color-border);
}

컴포넌트는 다크모드를 모른다. semantic 토큰만 보고 자동 적응.


What — 전략 3: Per-Component (드물게 사용)

특정 컴포넌트만 영구 다크로 (예: 코드 블록은 항상 다크).

.code-block {
  --code-bg:   #0d1117;
  --code-text: #c9d1d9;
  /* 사이트 테마와 무관하게 항상 다크 */
  color-scheme: dark; /* 내부 스크롤바도 다크 */
  background: var(--code-bg);
  color:      var(--code-text);
}

color-scheme요소 단위로 줄 수 있다는 게 강력하다. 예: 헤더만 다크, 푸터만 라이트.

언제 쓰나: 코드 블록, 다이얼로그, 인앱 임베디드 비디오 플레이어 등 맥락이 강한 부분.


What — Tailwind v4의 다크모드

Tailwind v4는 3가지 모드:

// tailwind.config (v3) — 미디어 쿼리 모드
darkMode: 'media',
// 또는 클래스 모드
darkMode: 'class',
// 또는 selector 모드 (v3.4+)
darkMode: ['selector', '[data-theme="dark"]'],
/* v4 CSS-first */
@import "tailwindcss";
 
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
<div class="bg-white dark:bg-gray-900 text-black dark:text-white">

→ 모든 유틸리티에 dark: variant 적용.

: Tailwind의 dark: 유틸을 너무 많이 쓰지 마라. semantic 토큰으로 추상화하면:

@theme {
  --color-bg: light-dark(white, var(--color-gray-900));
}
<div class="bg-bg">  <!-- 한 번에 둘 다 처리 -->

What-if — 다크모드의 5가지 함정

1) FOUC (Flash of Unstyled Content)

SSR/SSG 시 서버는 다크인지 모름. 첫 페인트는 라이트, 그 후 JS가 다크로 전환깜빡임.

해결: <head> 최상단 인라인 스크립트:

<script>
  document.documentElement.dataset.theme = 
    localStorage.theme || 
    (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
</script>

2) 이미지·로고가 다크에서 안 보임

/* 다크 배경에 어두운 로고 */
img.logo { filter: brightness(0) invert(1); }
 
/* 또는 자동 반전 */
@media (prefers-color-scheme: dark) {
  img.logo { filter: invert(1) hue-rotate(180deg); }
}
 
/* 또는 다크 전용 이미지 */
<picture>
  <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="logo-light.svg" alt="">
</picture>

3) 그림자가 다크에서 사라짐

라이트 모드의 box-shadow: 0 4px 8px rgba(0,0,0,0.1)는 다크에서 안 보임 (검은 위에 검정).

:root {
  --shadow-card: light-dark(
    0 4px 8px rgba(0,0,0,0.1),
    0 4px 8px rgba(0,0,0,0.4)  /* 더 진하게 또는 outline으로 대체 */
  );
}

4) color-scheme 누락 → 폼 컨트롤 흰색

다크 페이지에 흰 input·select가 튀어나온다. :root { color-scheme: light dark } 반드시.

5) 너무 극단적 대비

#000#fff눈이 아프다. 모던 다크는 보통:

  • 배경: #0a0a0a ~ #1a1a1a
  • 텍스트: #e5e5e5 ~ #f0f0f0

WCAG 대비 4.5:1만 만족하면, 순수 검정/순수 흰색은 피하라. iOS·Material 가이드 모두 이 패턴.


What-if — Light-Dark 함수 미지원 브라우저

2024 Baseline이지만 구형 브라우저 대응:

:root {
  /* fallback */
  --color-text: black;
}
@supports (color: light-dark(black, white)) {
  :root {
    --color-text: light-dark(black, #e5e5e5);
  }
}
 
/* 또는 점진적 향상 */
@media (prefers-color-scheme: dark) {
  :root { --color-text: #e5e5e5; }
}
:root {
  --color-text: light-dark(black, #e5e5e5);
}

후자는 모던 브라우저는 light-dark(), 구형은 미디어 쿼리.


Insight — 다크모드의 3단 진화

다크모드는 기능에서 디자인 시스템의 일부로, 다시 언어 기능으로 진화했다.

2014-2018: 수동 시대

  • .dark 클래스를 컴포넌트마다 분기.
  • JS로 토글, localStorage 저장.
  • FOUC 끔찍.
  • 500개 컴포넌트면 500번 분기.

2018-2024: 토큰 시대

  • CSS Variables가 표준화.
  • semantic 토큰 층에서 한 번만 재매핑.
  • prefers-color-scheme 미디어 쿼리.
  • 코드 양 10배 감소.

2024~: 함수 시대

  • light-dark() 함수.
  • color-scheme 속성.
  • 컴포넌트는 다크모드를 모른다.
  • 다크모드 추가에 수십 줄.

흥미로운 점: 다크모드는 디자인 시스템의 시험대다. 토큰이 잘 설계됐다면 다크모드는 몇 시간에 끝난다. 안 됐다면 몇 주가 든다.

Robin Rendle은 “Dark mode is a stress test for your design system” 이라고 했다. 토큰 없는 디자인 시스템은 다크모드에서 드러난다.

2024년 light-dark()가 표준이 된 것은 CSS가 디자인 시스템의 일부 결정을 언어로 흡수한 사례다. 함수 하나에 의미가 응축됐다 — “두 가지 모드 사이의 선택”.

오늘날의 다크모드는 더 이상 *“다크모드를 추가하자”*는 프로젝트가 아니다. 디자인 시스템이 잘 설계됐다면 이미 거기 있다 .


요약 + Mermaid

  • 3 전략: System Only / Manual Override(표준) / Per-Component.
  • color-scheme: light dark — OS 폼 컨트롤·스크롤바까지 다크.
  • light-dark(a, b) — 한 줄로 두 모드 표현 (2024 Baseline).
  • semantic 토큰 층이 다크모드의 유일한 분기 지점.
  • FOUC 방지: head 최상단 인라인 스크립트.
  • WCAG 대비 유지하면서 순수 검정·흰색은 피한다.

다음: 08-perf-and-shipping — Critical CSS·content-visibility·container query 비용.