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.csstheme-dark.css 두 곳에 복제됐다.
  • 새 색을 하나 추가하면 두 파일을 동시에 수정해야 했다.
  • Card만 다크로 보이고 싶다 → 불가능. 페이지 전체가 다크 아니면 전체가 라이트.
풀려는 문제이전 해법한계
”다크 버전을 어떻게 만들지?”theme-dark.css에 모든 색 재정의코드 두 벌, 복제·드리프트
”Card 하나만 다크로?”컴포넌트별 dark prop100곳 분기
”브랜드 두 개?”빌드 두 번번들 2배, 배포 분기
”다크에서 그림자 색 다르게?“shadow도 컴포넌트마다 분기거버넌스 붕괴

해법은 2016년경 Material DesignStitches가 보여준 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}' },
        },
      },
    },
  },
});

semanticTokensvalue가 객체일 때 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 자체에 _dark override를 박으면, 새 컴포넌트가 추가될 때마다 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를 가리키게 만드는 일이다.
  • primitivecomponent는 컨텍스트와 무관해야 한다 — 둘 다 컨텍스트 분기가 있다면 토큰 계층이 잘못된 것.
  • DTCG $extensions, Panda semanticTokens 객체값, Tailwind v4 @theme는 모두 같은 메커니즘의 다른 표기다.
  • 분기 지점이 한 곳이면 새 테마 추가가 토큰 한 줄이고, 100곳이면 불가능이다.