🧩 Design System2. Color System (OKLCH·다크모드)06 — Color Token Mapping (Panda × Tailwind)

06 — Color Token Mapping (Panda × Tailwind)

이 문서가 답하는 질문: Panda CSS와 Tailwind를 같은 앱에서 쓰는데, 같은 색이 두 시스템에서 같은 OKLCH 값이라는 걸 어떻게 보장하는가? 한쪽만 바꾸고 다른 쪽을 잊어버리는 사고를 어떻게 막는가? 한 줄 답 (Pyramid Top): “토큰을 두 config 바깥에 두고, 두 config는 그걸 import만 한다” — 즉 Single Source of Truthtokens/colors.ts (또는 W3C DTCG JSON) 한 파일이고, panda.config.ts·tailwind.config.ts·CSS variables가 모두 그 한 파일에서 코드로 파생된다. 한 줄 바꾸면 세 시스템이 동시에 갱신된다.


Why — 왜 한 앱에 두 시스템이 있는가

흔한 시나리오

상황결과
신규 기능은 Panda recipe, 레거시 페이지는 Tailwind utility같은 앱, 두 컴파일러
Marketing 사이트는 Tailwind (속도), 앱은 Panda (타입 안전)같은 monorepo
shadcn/ui (Tailwind 기반)을 쓰면서 자체 컴포넌트는 Panda색이 어긋남
Tailwind v3 → v4 마이그레이션 중 Panda 도입임시 공존

이 상황에서 색을 두 번 정의하면 반드시 어긋난다. 누군가 한쪽만 바꾸고 잊는다.


How — SSOT 아키텍처

3계층 구조

핵심: SSOT는 그냥 데이터 — config가 아니다. .ts 또는 W3C DTCG JSON. 다른 모든 파일이 이 데이터에서 파생된다.

구조의 변환 흐름

단계입력출력
1. SSOT 정의(사람이 작성)tokens/colors.ts
2. Panda 변환colors.tspanda.config.tstokens.colors 객체
3. Tailwind 변환colors.tstailwind.config.tstheme.extend.colors 객체
4. CSS 변환colors.tstokens.css:root 블록
5. APCA 검증colors.tsscripts/check-contrast.mjs 검증 결과

4-output에서 5번 검증까지가 빌드 파이프라인이다.


What — 코드로 보는 구현

Step 1: SSOT 정의 (TypeScript)

// tokens/colors.ts
type ColorScale = Record<1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12, string>;
 
export const colors = {
  light: {
    gray: {
      1:  'oklch(0.994 0 0)',
      2:  'oklch(0.982 0 0)',
      3:  'oklch(0.961 0 0)',
      4:  'oklch(0.938 0 0)',
      5:  'oklch(0.907 0 0)',
      6:  'oklch(0.869 0 0)',
      7:  'oklch(0.819 0 0)',
      8:  'oklch(0.741 0 0)',
      9:  'oklch(0.553 0 0)',
      10: 'oklch(0.516 0 0)',
      11: 'oklch(0.482 0 0)',
      12: 'oklch(0.249 0 0)',
    } as ColorScale,
    blue: {
      1:  'oklch(0.994 0.002 247.85)',
      // ... 12 steps
      9:  'oklch(0.553 0.234 247.85)',
      12: 'oklch(0.249 0.085 247.85)',
    } as ColorScale,
    // red, green, yellow ...
  },
  dark: {
    gray: {
      1:  'oklch(0.142 0 0)',
      12: 'oklch(0.941 0 0)',
      // ...
    } as ColorScale,
    blue: {
      9:  'oklch(0.609 0.205 247.85)',
      // ...
    } as ColorScale,
  },
};
 
// Semantic 매핑
export const semantic = {
  light: {
    'bg.page':        '{colors.light.gray.1}',
    'bg.surface':     '{colors.light.gray.2}',
    'bg.component':   '{colors.light.gray.3}',
    'bg.hover':       '{colors.light.gray.4}',
    'border.default': '{colors.light.gray.7}',
    'text.primary':   '{colors.light.gray.12}',
    'text.secondary': '{colors.light.gray.11}',
    'brand.solid':    '{colors.light.blue.9}',
    'brand.hover':    '{colors.light.blue.10}',
  },
  dark: {
    'bg.page':        '{colors.dark.gray.1}',
    'bg.surface':     '{colors.dark.gray.2}',
    'text.primary':   '{colors.dark.gray.12}',
    'brand.solid':    '{colors.dark.blue.9}',
  },
};

Step 2: Panda config 주입

// panda.config.ts
import { defineConfig, defineTokens, defineSemanticTokens } from '@pandacss/dev';
import { colors, semantic } from './tokens/colors';
 
// Panda는 { value: '...' } 형식을 요구 → 변환
function toPandaTokens(scale: Record<string, string>) {
  return Object.fromEntries(
    Object.entries(scale).map(([k, v]) => [k, { value: v }])
  );
}
 
export default defineConfig({
  preflight: true,
  theme: {
    tokens: defineTokens({
      colors: {
        gray:  toPandaTokens(colors.light.gray),
        blue:  toPandaTokens(colors.light.blue),
      },
    }),
    semanticTokens: defineSemanticTokens({
      colors: {
        bg: {
          page:    { value: { base: colors.light.gray[1],  _dark: colors.dark.gray[1] } },
          surface: { value: { base: colors.light.gray[2],  _dark: colors.dark.gray[2] } },
        },
        text: {
          primary: { value: { base: colors.light.gray[12], _dark: colors.dark.gray[12] } },
        },
        brand: {
          solid: { value: { base: colors.light.blue[9], _dark: colors.dark.blue[9] } },
        },
      },
    }),
  },
  conditions: {
    dark: '[data-theme="dark"] &',
    light: '[data-theme="light"] &',
  },
});

Panda 컴포넌트에서:

import { css } from 'styled-system/css';
 
<button className={css({
  bg: 'brand.solid',
  color: 'white',
  _hover: { bg: 'brand.hover' },
})} />

Step 3: Tailwind config 주입

// tailwind.config.ts
import type { Config } from 'tailwindcss';
import { colors, semantic } from './tokens/colors';
 
// Tailwind는 { 1: 'oklch(...)' } 형식을 그대로 받음
// 단, semantic은 CSS variables로 가야 다크모드 cascade가 동작
 
export default {
  content: ['./src/**/*.{ts,tsx}'],
  darkMode: ['class', '[data-theme="dark"]'],
  theme: {
    extend: {
      colors: {
        // Primitive: 직접 OKLCH 값
        gray:  colors.light.gray,
        blue:  colors.light.blue,
 
        // Semantic: CSS variables (다크모드 자동 cascade)
        bg: {
          page:    'var(--color-bg-page)',
          surface: 'var(--color-bg-surface)',
        },
        text: {
          primary:   'var(--color-text-primary)',
          secondary: 'var(--color-text-secondary)',
        },
        brand: {
          solid: 'var(--color-brand-solid)',
          hover: 'var(--color-brand-hover)',
        },
      },
    },
  },
} satisfies Config;

Tailwind utility에서:

<button className="bg-brand-solid hover:bg-brand-hover text-white" />

Step 4: CSS variables 생성

// scripts/build-css-tokens.mjs
import fs from 'node:fs';
import { colors, semantic } from '../tokens/colors';
 
let css = ':root {\n';
css += '  color-scheme: light dark;\n\n';
 
// Primitive
for (const [name, scale] of Object.entries(colors.light)) {
  for (const [step, value] of Object.entries(scale)) {
    css += `  --${name}-${step}: ${value};\n`;
  }
}
 
css += '\n  /* Semantic — light */\n';
for (const [key, ref] of Object.entries(semantic.light)) {
  // {colors.light.gray.1} → var(--gray-1)
  const resolved = ref.replace(
    /\{colors\.light\.(\w+)\.(\d+)\}/g,
    (_, hue, step) => `var(--${hue}-${step})`
  );
  css += `  --color-${key.replace('.', '-')}: ${resolved};\n`;
}
 
css += '}\n\n[data-theme="dark"] {\n';
// dark primitives
for (const [name, scale] of Object.entries(colors.dark)) {
  for (const [step, value] of Object.entries(scale)) {
    css += `  --${name}-${step}: ${value};\n`;
  }
}
css += '\n  /* Semantic — dark */\n';
for (const [key, ref] of Object.entries(semantic.dark || {})) {
  const resolved = ref.replace(
    /\{colors\.dark\.(\w+)\.(\d+)\}/g,
    (_, hue, step) => `var(--${hue}-${step})`
  );
  css += `  --color-${key.replace('.', '-')}: ${resolved};\n`;
}
css += '}\n';
 
fs.writeFileSync('./src/tokens.css', css);
console.log('✓ Built tokens.css');
node scripts/build-css-tokens.mjs

Step 5: 정합성 검증

// scripts/check-token-parity.mjs
import { colors } from '../tokens/colors.js';
 
// Panda 컴파일 결과의 변수와 Tailwind 컴파일 결과의 변수가 같은가?
// 둘 다 SSOT에서 파생되므로 *원리상* 같아야 함 — 의도된 검증
function flatten(obj, prefix = '') {
  const out = {};
  for (const [k, v] of Object.entries(obj)) {
    const key = prefix ? `${prefix}.${k}` : k;
    if (typeof v === 'object') Object.assign(out, flatten(v, key));
    else out[key] = v;
  }
  return out;
}
 
const light = flatten(colors.light);
console.log(`Total tokens: ${Object.keys(light).length}`);
 
// 잘못 등록된 hex가 없는지
for (const [k, v] of Object.entries(light)) {
  if (!v.startsWith('oklch')) {
    console.error(`✗ Non-OKLCH token: ${k} = ${v}`);
    process.exit(1);
  }
}
console.log('✓ All tokens are OKLCH');

What — W3C DTCG JSON으로 확장

더 확장 가능한 패턴 — DTCG 형식

{
  "$schema": "https://design-tokens.github.io/community-group/format/",
  "color": {
    "gray": {
      "1": { "$value": "oklch(0.994 0 0)", "$type": "color" },
      "9": { "$value": "oklch(0.553 0 0)", "$type": "color" },
      "12": { "$value": "oklch(0.249 0 0)", "$type": "color" }
    },
    "blue": {
      "9": { "$value": "oklch(0.553 0.234 247.85)", "$type": "color" }
    }
  },
  "bg": {
    "page": {
      "$value": "{color.gray.1}",
      "$type": "color",
      "$extensions": {
        "darkmode": "{color.gray.1.dark}"
      }
    }
  }
}

장점:

  • Figma Tokens Studio 같은 도구가 같은 JSON을 직접 import/export
  • Style Dictionary가 같은 JSON을 iOS·Android·CSS로 동시 변환
  • 디자이너-개발자 협업의 사실상 표준
# Style Dictionary 빌드
npx style-dictionary build

→ 자세히는 ../01-tokens 챕터.

DTCG → Panda/Tailwind 자동 변환

// 이 도구가 사실상 다 있음
import { register } from '@tokens-studio/sd-transforms';
import StyleDictionary from 'style-dictionary';
 
register(StyleDictionary);
 
const sd = new StyleDictionary({
  source: ['tokens/**/*.json'],
  platforms: {
    panda:    { transformGroup: 'panda',    files: [...] },
    tailwind: { transformGroup: 'tailwind', files: [...] },
    css:      { transformGroup: 'css',      files: [...] },
  },
});
 
sd.buildAllPlatforms();

../07-panda-tailwind-interop 챕터에서 자세히.


What — Multi-brand × multi-theme 확장

4차원 토큰 (brand × theme × scale × hue)

// tokens/colors.ts
export const colors = {
  brandA: {
    light: { /* 12 steps × N hues */ },
    dark:  { /* ... */ },
  },
  brandB: {
    light: { /* ... */ },
    dark:  { /* ... */ },
  },
  // ...
};

CSS 출력:

:root                                    { /* brandA + light */ }
:root[data-theme="dark"]                 { /* brandA + dark */ }
:root[data-brand="b"]                    { /* brandB + light */ }
:root[data-brand="b"][data-theme="dark"] { /* brandB + dark */ }

Tailwind는 plugin으로:

import plugin from 'tailwindcss/plugin';
 
theme: {
  extend: {
    colors: {
      brand: 'var(--color-brand-solid)',  // 그대로
    },
  },
},
plugins: [
  plugin(({ addBase }) => {
    addBase({
      ':root': { '--color-brand-solid': 'oklch(0.55 0.23 247)' },
      ':root[data-brand="b"]': { '--color-brand-solid': 'oklch(0.58 0.19 140)' },
    });
  }),
],

Panda는 conditions로:

conditions: {
  brandB: '[data-brand="b"] &',
},

What-if — 잘못 다루면

1) Panda는 OKLCH, Tailwind는 hex

// panda.config.ts
gray: { 9: { value: 'oklch(0.553 0 0)' } }
 
// tailwind.config.ts
gray: { 500: '#888' }   // ✗ 다른 색

같은 컴포넌트가 Panda recipe로 쓰면 한 색, Tailwind className으로 쓰면 다른 색. SSOT가 깨졌다.

2) SSOT를 config 파일 안에 두기

// ✗ tailwind.config.ts에 직접 박힘
theme: {
  extend: {
    colors: {
      blue: { 500: 'oklch(...)', ... },   // 다른 곳에서 import 못 함
    },
  },
},

Panda config가 이 색을 알 방법이 없음. SSOT는 config 바깥data 파일에 있어야 한다.

3) Tailwind에 primitive를 OKLCH로 직접 박고, semantic은 CSS variables로 박지 않기

// ✗ Tailwind에서 다크모드 자동 전환 안 됨
colors: {
  bg: { surface: 'oklch(0.98 0 0)' },   // light로 fix
}

다크모드 전환이 안 된다. semantic은 반드시 CSS variables로 가야 한다.

// ✓
colors: {
  bg: { surface: 'var(--color-bg-surface)' },
}

4) Tailwind 클래스에 primitive를 직접 쓰기

<button className="bg-blue-500" />   {/* primitive 직접 사용 */}

다크모드에서 같은 색 그대로 → semantic 깨짐. Tailwind className도 semantic만:

<button className="bg-brand-solid" />   {/* semantic */}

Tailwind v4의 @theme directive가 이 패턴을 더 잘 지원한다.

5) DTCG의 $value 참조 형식 무시

{ "bg": { "page": { "$value": "{color.gray.1}" } } }

{color.gray.1} 참조를 수동으로 resolve하면 오타 위험. Style Dictionary나 DTCG resolver를 거쳐야 안전.

6) Build pipeline 없이 손으로 두 config 동기화

# 매번 두 파일을 손으로 수정
edit panda.config.ts
edit tailwind.config.ts
edit tokens.css

→ 1주 안에 어긋난다. 스크립트가 4개 파일 동시 생성하는 게 답.


Insight — 왜 “두 시스템 공존”이 영구 상태인가

“마이그레이션은 결승선이 아니라 영구 트랙이다”

2024년 한 해 동안 Tailwind v3 → v4 마이그레이션이 대부분의 회사에서 진행 중이었다. 동시에 Panda CSS가 zero-runtime + type safety로 성숙해갔다. 많은 팀이 둘 다 가져가는 결정을 내렸다.

이유는 단순하지 않다. 기술적으로 Panda가 우수한 지점(타입 안전, recipe, slot) + 생태계적으로 Tailwind가 강한 지점(shadcn/ui, Tailwind UI, 무수한 예제). 한쪽으로 통일하려면 다른 쪽의 강점을 포기해야 한다.

그래서 공존이 정답이 됐다. 그리고 공존을 깨지지 않게 유지하는 유일한 방법이 SSOT다.

흥미로운 반전 — Tailwind v4가 CSS-first config로 가면서 Panda와의 접점이 더 커졌다. v4의 @theme directive는 Panda의 토큰 선언과 형태가 비슷하다. 언젠가는 둘이 같은 토큰 포맷을 합의할 가능성이 있다 — 그게 W3C DTCG다.

W3C DTCG (Design Tokens Community Group)는 Adobe, Figma, MS, Google이 함께하는 표준화 작업. 2024년 editor’s draft가 안정화됐다. 2026년 즈음엔 Tailwind v5와 Panda v1둘 다 DTCG를 native로 import할 가능성이 있다. 그땐 SSOT 빌드 스크립트가 필요 없어진다둘 다 같은 JSON을 직접 읽는다.

그러나 그날이 와도 디자인 시스템 팀은 SSOT 패턴을 유지한다. 왜? iOS·Android·Email·Slack 알림 색까지 같은 출처에서 파생해야 하기 때문. SSOT는 컴파일러 합의의 문제가 아니라 조직의 진실의 단일성 문제다.


요약

  • SSOT 1개 + config 3개 + 검증 스크립트 1개 = 안전한 공존.
  • SSOT는 .ts 또는 W3C DTCG JSONconfig가 아닌 데이터.
  • Panda는 tokens.colors + semanticTokens, Tailwind는 theme.extend.colors, CSS는 :root 변수.
  • Primitive는 직접 OKLCH 값, Semantic은 CSS variables — 다크모드 자동 cascade.
  • 빌드 파이프라인이 *4-output (panda/tw/css/검증)*을 동시 생성.
  • W3C DTCG가 미래의 native 포맷 — 2026년 즈음 직접 import 가능성.

다음 챕터 — ../03-typography-spacing — 같은 SSOT 패턴을 수치(폰트·간격·radius) 토큰에 확장.