다크모드 컬러 시스템 — Semantic Token으로 테마 분기 없애기

디자인 시스템

다크모드컬러 시스템디자인 토큰Figma VariablesCSS Custom Properties

이 글은 누구를 위한 것인가

  • 다크모드를 추가하려는데 컴포넌트마다 isDark ? colorA : colorB 분기가 생기는 팀
  • 디자인 토큰을 도입했지만 다크모드 토큰 설계에서 막힌 디자이너·프론트엔드 엔지니어
  • Figma Variables와 CSS Custom Property를 연결하는 워크플로를 찾는 팀

다크모드가 어려운 이유

많은 팀이 다크모드를 이렇게 구현한다.

/* 안티패턴 */
.button {
  background: #1a73e8; /* 라이트 */
}
[data-theme="dark"] .button {
  background: #8ab4f8; /* 다크 */
}

또는 컴포넌트에서:

// 안티패턴
const bg = isDark ? '#8ab4f8' : '#1a73e8';

두 방식 모두 색상이 의미 없는 값(hex)으로 하드코딩된다는 문제가 있다. 컴포넌트가 늘어날수록 두 테마를 별도로 유지해야 하고, 토큰 변경 한 번이 수십 개 파일 수정으로 이어진다.


토큰 계층: Primitive → Semantic → Component

컬러 토큰은 세 계층으로 분리해야 한다.

1계층: Primitive Token (팔레트)

모든 색상의 원자값. 의미 없이 색상만 기술한다.

color.blue.50  = #eff6ff
color.blue.100 = #dbeafe
color.blue.500 = #3b82f6
color.blue.600 = #2563eb
color.gray.0   = #ffffff
color.gray.950 = #030712

Primitive token은 절대 컴포넌트에서 직접 참조하지 않는다. 이 계층은 변경이 거의 없는 브랜드 팔레트다.

2계층: Semantic Token (의미)

UI의 역할을 표현한다. 이 계층이 라이트/다크 테마 분기를 모두 흡수한다.

시맨틱 토큰라이트다크
color.background.defaultgray.0gray.950
color.background.subtlegray.50gray.900
color.text.primarygray.900gray.50
color.text.secondarygray.600gray.400
color.text.disabledgray.400gray.600
color.border.defaultgray.200gray.700
color.interactive.primaryblue.600blue.400
color.interactive.primaryHoverblue.700blue.300
color.status.successgreen.600green.400
color.status.errorred.600red.400
color.status.warningamber.600amber.400

핵심: 컴포넌트는 semantic token만 참조한다. 테마가 바뀌어도 컴포넌트 코드는 수정할 필요가 없다.

3계층: Component Token (선택적)

버튼, 카드 같은 특정 컴포넌트의 세밀한 제어가 필요할 때만 추가한다.

color.button.primary.background       = color.interactive.primary
color.button.primary.backgroundHover  = color.interactive.primaryHover
color.button.primary.text             = color.gray.0

컴포넌트 토큰을 추가하기 전에 semantic token으로 해결할 수 없는지 먼저 검토한다.


CSS Custom Property로 바인딩하기

Semantic token을 CSS Custom Property에 매핑하면 JS 없이 CSS만으로 테마 전환이 가능하다.

토큰 정의

/* tokens/primitive.css */
:root {
  --color-blue-400: #60a5fa;
  --color-blue-600: #2563eb;
  --color-gray-0: #ffffff;
  --color-gray-50: #f9fafb;
  --color-gray-200: #e5e7eb;
  --color-gray-400: #9ca3af;
  --color-gray-600: #4b5563;
  --color-gray-900: #111827;
  --color-gray-950: #030712;
}
/* tokens/semantic.css */
:root {
  /* 라이트 기본값 */
  --color-bg-default: var(--color-gray-0);
  --color-bg-subtle: var(--color-gray-50);
  --color-text-primary: var(--color-gray-900);
  --color-text-secondary: var(--color-gray-600);
  --color-text-disabled: var(--color-gray-400);
  --color-border-default: var(--color-gray-200);
  --color-interactive-primary: var(--color-blue-600);
}

/* 다크 모드: semantic token 값만 교체 */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-default: var(--color-gray-950);
    --color-bg-subtle: var(--color-gray-900);
    --color-text-primary: var(--color-gray-50);
    --color-text-secondary: var(--color-gray-400);
    --color-text-disabled: var(--color-gray-600);
    --color-border-default: var(--color-gray-700);
    --color-interactive-primary: var(--color-blue-400);
  }
}

/* 사용자 수동 토글 지원 */
[data-theme="dark"] {
  --color-bg-default: var(--color-gray-950);
  /* ... 동일하게 반복 */
}

컴포넌트에서 사용

/* components/Button.css */
.button-primary {
  background-color: var(--color-interactive-primary);
  color: var(--color-gray-0);
  border: 1px solid var(--color-border-default);
}

테마가 바뀌어도 .button-primary는 수정이 없다. --color-interactive-primary가 자동으로 알맞은 값으로 바뀐다.


시스템 설정 감지와 수동 토글 병행

prefers-color-scheme만으로는 부족하다. 사용자가 앱 내에서 직접 테마를 선택할 수 있어야 한다.

우선순위 설계

1. 사용자 명시적 선택 (localStorage 저장)
2. 시스템 설정 (prefers-color-scheme)
3. 기본값 (라이트)
// theme.ts
type Theme = 'light' | 'dark' | 'system';

function applyTheme(theme: Theme) {
  const root = document.documentElement;

  if (theme === 'system') {
    root.removeAttribute('data-theme');
    // CSS @media prefers-color-scheme가 자동 처리
    return;
  }

  root.setAttribute('data-theme', theme);
}

function initTheme() {
  const saved = localStorage.getItem('theme') as Theme | null;
  applyTheme(saved ?? 'system');
}

SSR 환경 (Next.js)에서 FOUC 방지:

<!-- _document.tsx의 <head> 내부 -->
<script dangerouslySetInnerHTML={{ __html: `
  (function() {
    var t = localStorage.getItem('theme');
    if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.setAttribute('data-theme', 'dark');
    }
  })();
`}} />

이 스크립트를 hydration 전에 실행해 초기 렌더링에서 테마가 깜빡이는 현상을 막는다.


Figma Variables 연동

Figma Variables(2023년 정식 출시)는 이 토큰 계층을 디자인 도구에서도 동일하게 표현할 수 있게 한다.

Figma 컬렉션 구조

컬렉션: Primitive
  │  Mode: Default
  │  └─ color/blue/600: #2563eb
  │  └─ color/gray/950: #030712
  │
컬렉션: Semantic
     Mode: Light | Dark
     └─ color/background/default
          Light: {color/gray/0}
          Dark:  {color/gray/950}
     └─ color/text/primary
          Light: {color/gray/900}
          Dark:  {color/gray/50}

팀 운영 규칙:

  • Primitive 컬렉션은 디자인 시스템 팀만 수정
  • Semantic 컬렉션 추가는 PR 리뷰를 거친다
  • 컴포넌트는 Semantic 컬렉션의 변수만 참조
  • Light/Dark 모드 미리보기로 다크모드를 즉시 검증

코드와 Figma 동기화

토큰 값이 Figma와 코드에서 이중 관리되면 불일치가 생긴다. 다음 중 하나를 선택한다:

  1. Figma → 코드: Style Dictionary, Tokens Studio for Figma로 Figma Variables를 CSS/JSON으로 내보내기
  2. 코드 → Figma: 코드의 JSON 토큰 파일을 Figma에 import
  3. 단방향 명세 문서: Figma는 시각적 명세, 코드는 구현 원본으로 역할을 분리하고 주기적 감사

규모가 작은 팀은 3번이 현실적이다. 다만 분기마다 Figma-코드 토큰 값을 비교하는 자동 검사 스크립트가 있으면 불일치를 조기에 발견할 수 있다.


흔한 실수와 피하는 법

1. Primitive token을 컴포넌트에서 직접 사용 var(--color-blue-600)을 컴포넌트에 쓰면 다크모드에서 수동으로 다시 지정해야 한다. 반드시 semantic token을 거친다.

2. Semantic token 과잉 생성 --color-button-primary-hover-border-focused-disabled 같은 토큰은 관리 비용만 높인다. Semantic token은 "이 색이 어떤 역할인가"가 명확할 때만 추가한다.

3. 어두운 배경 + 어두운 글자색 조합 검증 누락 다크모드 Semantic token을 정의한 뒤에는 반드시 WCAG AA 기준(4.5:1 대비율)을 도구로 검증한다. Figma의 Contrast 플러그인이나 color-contrast() CSS 함수를 활용한다.

4. 토큰 이름에 색상값 포함 --color-blue-primary는 브랜드 컬러가 바뀌면 이름이 틀려진다. --color-interactive-primary처럼 역할 중심으로 명명한다.


맺으며

다크모드 구현의 복잡성은 대부분 컬러 토큰 계층 없이 색상을 직접 참조하는 데서 비롯된다. Primitive → Semantic 계층을 한 번 정의해두면 컴포넌트는 테마를 알 필요가 없어지고, 다크모드는 CSS Custom Property 값 교체 한 번으로 완성된다.

Figma Variables와 CSS Custom Property가 동일한 계층 구조를 공유하면 디자이너와 엔지니어가 같은 언어로 소통할 수 있다. 지금 당장 모든 토큰을 다 정의할 필요는 없다. background, text, border, interactive 네 가지 semantic 카테고리부터 시작해도 충분히 구조가 잡힌다.