01 — Theme as Context
이 문서가 답하는 질문: “테마”는 정확히 무엇을 바꾸는 것인가? 컴포넌트 코드 안에서
if (dark) ...분기가 단 한 줄도 등장하지 않으려면, 토큰 계층을 어떻게 끊어야 하는가? 한 줄 답 (Pyramid Top): 테마는 “semantic토큰만 다른primitive를 가리키도록 만드는 간접 참조의 재배선” 이다 —primitive(blue.500=#3370b8)도component(button.bg.primary)도 그대로, 오직 중간 층(color.bg.accent)만 컨텍스트마다 다른 primitive를 가리킨다.
Why — 왜 존재하는가
2014년 즈음 “테마”라는 단어는 “색 팔레트를 통째로 교체” 를 뜻했다. CSS 파일을 두 벌 만들고 <link>를 swap하거나, body.theme-dark 같은 클래스 아래 모든 색을 다시 적었다. 그 결과:
- 똑같은 컴포넌트가
theme-light.css와theme-dark.css두 곳에 복제됐다. - 새 색을 하나 추가하면 두 파일을 동시에 수정해야 했다.
Card만 다크로 보이고 싶다 → 불가능. 페이지 전체가 다크 아니면 전체가 라이트.
| 풀려는 문제 | 이전 해법 | 한계 |
|---|---|---|
| ”다크 버전을 어떻게 만들지?” | theme-dark.css에 모든 색 재정의 | 코드 두 벌, 복제·드리프트 |
| ”Card 하나만 다크로?” | 컴포넌트별 dark prop | 100곳 분기 |
| ”브랜드 두 개?” | 빌드 두 번 | 번들 2배, 배포 분기 |
| ”다크에서 그림자 색 다르게?“ | shadow도 컴포넌트마다 분기 | 거버넌스 붕괴 |
해법은 2016년경 Material Design과 Stitches가 보여준 3-tier 토큰 + 간접 참조다:
primitive → semantic → component
blue.500 → color.bg.accent → button.bg.primary
(이 화살표만 컨텍스트마다 다시 그린다)테마는 이 가운데 화살표를 다시 그리는 일이다. 양 끝(primitive와 component)은 손대지 않는다.
How — 어떻게 동작하는가
핵심 메커니즘은 CSS Custom Property의 cascade 다.
/* primitive — 절대 안 바뀜 */
:root {
--blue-500: #3370b8;
--blue-400: #4a8cd4;
--gray-50: #fafafa;
--gray-900: #18181b;
}
/* semantic — light 기본 */
:root {
--color-bg-surface: var(--gray-50);
--color-bg-accent: var(--blue-500);
--color-fg-default: var(--gray-900);
}
/* semantic — dark는 *같은 이름*에 다른 primitive 바인딩 */
[data-theme="dark"] {
--color-bg-surface: var(--gray-900);
--color-bg-accent: var(--blue-400);
--color-fg-default: var(--gray-50);
}
/* component — 한 번도 분기하지 않는다 */
.button-primary {
background: var(--color-bg-accent);
color: var(--color-fg-default);
}<html data-theme="dark">만 토글하면 모든 컴포넌트가 자동으로 다크가 된다. 컴포넌트 CSS는 단 한 줄도 안 바뀐다.
What — 구체 사양 / 수치 / 예시
분기 지점은 한 곳뿐이다
| 토큰 층 | 컨텍스트별 분기? | 예시 |
|---|---|---|
primitive (raw palette) | NO — 절대 바꾸지 않음 | blue.500=#3370b8 |
semantic (intent) | YES — 여기서만 분기 | color.bg.surface |
component (recipe-specific) | NO — semantic만 참조 | button.bg.primary = color.bg.accent |
DTCG 표현
{
"blue": {
"500": { "$value": "#3370b8", "$type": "color" },
"400": { "$value": "#4a8cd4", "$type": "color" }
},
"color": {
"bg": {
"accent": {
"$value": "{blue.500}",
"$type": "color",
"$extensions": {
"design-system.dark": "{blue.400}"
}
}
}
},
"button": {
"bg": {
"primary": { "$value": "{color.bg.accent}", "$type": "color" }
}
}
}
$extensions는 DTCG 표준의 확장 슬롯. 여기에 모드별 override를 담는 것이 사실상 컨벤션이다.
Panda CSS 표현
// panda.config.ts
import { defineConfig } from '@pandacss/dev';
export default defineConfig({
conditions: {
light: '[data-theme=light] &',
dark: '[data-theme=dark] &',
},
theme: {
tokens: {
colors: {
blue: {
500: { value: '#3370b8' },
400: { value: '#4a8cd4' },
},
},
},
semanticTokens: {
colors: {
'bg.surface': {
value: { base: '{colors.gray.50}', _dark: '{colors.gray.900}' },
},
'bg.accent': {
value: { base: '{colors.blue.500}', _dark: '{colors.blue.400}' },
},
},
},
},
});semanticTokens의 value가 객체일 때 base / _dark 같은 condition 키를 받는다 — 이게 Panda가 간접 참조의 재배선을 1급 시민으로 표현하는 방식.
Tailwind v4 표현
/* app.css */
@import "tailwindcss";
@theme {
--color-blue-500: #3370b8;
--color-blue-400: #4a8cd4;
--color-bg-surface: var(--color-gray-50);
--color-bg-accent: var(--color-blue-500);
}
@layer base {
[data-theme="dark"] {
--color-bg-surface: var(--color-gray-900);
--color-bg-accent: var(--color-blue-400);
}
}이제 bg-bg-surface 같은 유틸이 한 클래스로 작동하고, 라이트/다크 모두 같은 마크업으로 처리된다.
What-if — 잘못 쓰면 어떻게 깨지는가
- 함정 1 — component 토큰에서 분기:
button.bg.primary자체에_darkoverride를 박으면, 새 컴포넌트가 추가될 때마다card.bg,dialog.bg, … 100곳에 같은 분기가 따라붙는다. 정답은 semantic 한 곳(color.bg.accent)에만 박고 모든 component가 그걸 참조하는 것. - 함정 2 — primitive를 컨텍스트마다 다르게: “다크에서는 blue를 좀 더 밝게 쓰고 싶다”고
blue.500자체를_dark에서 다른 hex로 정의 → 그러면 더 이상 primitive가 아니다. primitive는 단일 값. “더 밝은 파랑”이 필요하면blue.400을 만들고 semantic이 dark에서 그걸 가리키게 한다. - 함정 3 — 컴포넌트 안에서
useTheme()분기:if (theme === 'dark') style.color = ...같은 JS 분기. SSR FOUC, 번들 비대, 테마 추가 시 모든 컴포넌트 수정. CSS variables는 런타임에 이미 분기되어 있는 값이므로 컴포넌트는 무지여야 한다. - 함정 4 —
dark:bg-gray-900유틸을 모든 곳에 직접 박기 (Tailwind v3 스타일): 작동은 하지만 semantic 층이 없는 것이라 새 테마(고대비, 브랜드) 추가가 불가능하다. 유틸은bg-surface같은 semantic 이름을 쓰도록 토큰을 등록하라.
Insight — 흥미로운 이야기
2018년 Material Design 2.0이 “Surface · OnSurface”라는 개념을 들고 나왔을 때, 사람들은 처음엔 과한 추상화라고 여겼다.
당시 Material 1.0은 단순히 primary, secondary, background 정도였다. 2.0이 들고온 건 surface + on-surface라는 쌍이었다 — “이 배경 위에 올라가는 텍스트/아이콘 색은 항상 이것”이라는 관계를 토큰으로 박은 것.
이게 왜 혁명이었나? 다크모드를 만들 때 surface=#1c1c1c로만 바꾸면, on-surface=#f5f5f5도 그 surface와 짝지어진 채로 자동으로 적용된다. 컴포넌트 입장에서는 background: var(--surface); color: var(--on-surface); 두 줄이면 끝. 디자이너가 “다크에서는 그림자가 안 보이니 어두운 surface 위에는 밝은 그림자를 쓰자”고 결정해도, 컴포넌트는 모르고 살 수 있다.
Radix Colors(2022)는 한 발 더 나가서 12-step scale 안에 역할을 묻어버렸다 — step 1·2는 background, 3·4·5는 component surface, 6·7·8은 border, 9는 solid, 10·11·12는 text. 라이트 팔레트와 다크 팔레트가 같은 step 번호로 짝지어진다. 즉, gray.9는 라이트에서도 다크에서도 “solid background”라는 역할이 같다. 토큰을 bg.solid = gray.9로 박으면 컨텍스트별 분기조차 필요 없다 — Radix가 알아서 step의 물리적 색을 모드에 맞게 바꿔준다.
이게 바로 “테마 = 간접 참조의 재배선” 의 본질이다. 색을 바꾸는 게 아니라, 역할-색 매핑을 바꾸는 것.
요약
- 테마는 semantic 토큰만 다른 primitive를 가리키게 만드는 일이다.
primitive와component는 컨텍스트와 무관해야 한다 — 둘 다 컨텍스트 분기가 있다면 토큰 계층이 잘못된 것.- DTCG
$extensions, PandasemanticTokens객체값, Tailwind v4@theme는 모두 같은 메커니즘의 다른 표기다. - 분기 지점이 한 곳이면 새 테마 추가가 토큰 한 줄이고, 100곳이면 불가능이다.