이 글은 누구를 위한 것인가
- 다크모드를 추가하려는데 컴포넌트마다
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.default | gray.0 | gray.950 |
color.background.subtle | gray.50 | gray.900 |
color.text.primary | gray.900 | gray.50 |
color.text.secondary | gray.600 | gray.400 |
color.text.disabled | gray.400 | gray.600 |
color.border.default | gray.200 | gray.700 |
color.interactive.primary | blue.600 | blue.400 |
color.interactive.primaryHover | blue.700 | blue.300 |
color.status.success | green.600 | green.400 |
color.status.error | red.600 | red.400 |
color.status.warning | amber.600 | amber.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와 코드에서 이중 관리되면 불일치가 생긴다. 다음 중 하나를 선택한다:
- Figma → 코드: Style Dictionary, Tokens Studio for Figma로 Figma Variables를 CSS/JSON으로 내보내기
- 코드 → Figma: 코드의 JSON 토큰 파일을 Figma에 import
- 단방향 명세 문서: 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 카테고리부터 시작해도 충분히 구조가 잡힌다.