DTCG Tokens to Both
이 문서가 답하는 질문: 디자인 토큰을 한 벌의 JSON에서 정의하고, 양쪽 컴파일러의 config를 자동 생성하는 파이프라인은 어떻게 만드는가. 한 줄 답 (Pyramid Top): W3C DTCG JSON 한 벌을 Style Dictionary가 세 개의 출력(
tokens.css,tailwind.preset.ts,panda.tokens.ts)으로 변환하면, 디자이너의 색 변경 한 번이 세 출력 모두에 빌드 시점에 자동 반영된다. CSS variables는 SSOT의 런타임 표현, JS config는 빌드타임 표현 — 둘은 같은 JSON에서 파생된다.
Why — 왜 자동 생성이 필요한가
02번 문서에서 우리는 세 파일을 손으로 동기화했다:
tokens.css(CSS variables)tailwind.config.ts(각 토큰을var(--...)로 참조)panda.config.ts(각 토큰을var(--...)로 참조)
디자이너가 색 한 개를 추가/이름변경하면 세 파일을 손으로 고쳐야 한다. 토큰이 50개일 때는 견딜만 하지만, 300개를 넘기면 동기화 누락이 빈번해진다.
| 풀려는 문제 | 손 동기화 | Style Dictionary |
|---|---|---|
| 새 토큰 추가 | 3파일 수정 | JSON 1줄 수정 → 3출력 자동 |
| 이름 변경 | 3파일 + grep 검색 | 변경된 출력에 codemod 적용 |
| 디자이너 → 코드 흐름 | 디자이너가 hex를 PR 텍스트로 전달 | Figma plugin → DTCG JSON 직접 export |
| 다중 플랫폼 | iOS·Android 별도 정의 | 같은 JSON에서 iOS·Android도 생성 |
| 토큰 검증 | 없음 | $type 기반 type check + alias cycle 감지 |
핵심 통찰은 “토큰은 데이터이고 config는 함수의 결과” 다. 데이터가 SSOT면 config는 그 함수의 순수 출력이며, 손으로 짜는 것은 동기화 실패의 원천이다.
How — 파이프라인의 3단계
- 디자이너: Figma + Tokens Studio plugin이 DTCG JSON으로 export.
- Style Dictionary: JSON을 Token tree로 파싱 → 출력 포맷별 변환.
- 출력:
tokens.css+tailwind.preset.ts+panda.tokens.ts(+ iOS/Android 옵션).
What — 실제 동작 코드
1) DTCG JSON 토큰 정의
tokens/color.primitive.json:
{
"color": {
"blue": {
"50": { "$value": "oklch(0.97 0.02 250)", "$type": "color" },
"100": { "$value": "oklch(0.93 0.05 250)", "$type": "color" },
"500": { "$value": "oklch(0.62 0.20 250)", "$type": "color" },
"900": { "$value": "oklch(0.30 0.10 250)", "$type": "color" }
},
"gray": {
"50": { "$value": "oklch(0.98 0.00 250)", "$type": "color" },
"900": { "$value": "oklch(0.18 0.01 250)", "$type": "color" }
}
}
}tokens/color.semantic.json:
{
"color": {
"primary": {
"500": {
"$value": "{color.blue.500}",
"$type": "color",
"$description": "Brand primary — buttons, links, focus rings",
"$extensions": {
"ds.modes": {
"light": "{color.blue.500}",
"dark": "{color.blue.400}"
}
}
}
},
"bg": {
"surface": {
"$value": "{color.gray.50}",
"$type": "color",
"$extensions": {
"ds.modes": {
"light": "{color.gray.50}",
"dark": "{color.gray.900}"
}
}
}
},
"text": {
"default": {
"$value": "{color.gray.900}",
"$type": "color",
"$extensions": {
"ds.modes": {
"light": "{color.gray.900}",
"dark": "{color.gray.50}"
}
}
}
}
}
}tokens/spacing.json:
{
"spacing": {
"1": { "$value": "0.25rem", "$type": "dimension" },
"2": { "$value": "0.5rem", "$type": "dimension" },
"4": { "$value": "1rem", "$type": "dimension" },
"8": { "$value": "2rem", "$type": "dimension" }
}
}tokens/radii.json:
{
"radii": {
"md": { "$value": "0.5rem", "$type": "dimension" },
"lg": { "$value": "0.75rem", "$type": "dimension" }
}
}2) Style Dictionary 설정
build-tokens.mjs:
import StyleDictionary from 'style-dictionary'
import { fileHeader } from 'style-dictionary/utils'
// ───── 커스텀 포맷 1: tokens.css (light + dark)
StyleDictionary.registerFormat({
name: 'css/variables-with-modes',
format: ({ dictionary, file }) => {
const lines = ['@layer tokens {']
lines.push(' :root {')
for (const tok of dictionary.allTokens) {
const cssName = '--' + tok.path.join('-')
lines.push(` ${cssName}: ${tok.value};`)
}
lines.push(' }')
// Dark mode override (semantic tokens only)
lines.push(' :root[data-theme="dark"] {')
for (const tok of dictionary.allTokens) {
const dark = tok.$extensions?.['ds.modes']?.dark
if (!dark) continue
const cssName = '--' + tok.path.join('-')
// Resolve alias if needed
const resolved = dark.startsWith('{')
? `var(--${dark.slice(1, -1).split('.').join('-')})`
: dark
lines.push(` ${cssName}: ${resolved};`)
}
lines.push(' }')
lines.push('}')
return fileHeader({ file }) + lines.join('\n') + '\n'
},
})
// ───── 커스텀 포맷 2: tailwind preset
StyleDictionary.registerFormat({
name: 'tailwind/preset',
format: ({ dictionary, file }) => {
// 그룹: color / spacing / radii ...
const groups = {}
for (const tok of dictionary.allTokens) {
const [group, ...rest] = tok.path
groups[group] ??= {}
let node = groups[group]
for (let i = 0; i < rest.length - 1; i++) {
node[rest[i]] ??= {}
node = node[rest[i]]
}
node[rest[rest.length - 1]] = `var(--${tok.path.join('-')})`
}
const map = {
color: 'colors',
spacing: 'spacing',
radii: 'borderRadius',
}
const out = {}
for (const [k, v] of Object.entries(groups)) {
out[map[k] ?? k] = v
}
return [
'// AUTO-GENERATED. DO NOT EDIT.',
`// Source: tokens/*.json`,
`import type { Config } from 'tailwindcss'`,
``,
`export default {`,
` theme: {`,
` extend: ${JSON.stringify(out, null, 6)},`,
` },`,
`} satisfies Partial<Config>`,
``,
].join('\n')
},
})
// ───── 커스텀 포맷 3: panda tokens config fragment
StyleDictionary.registerFormat({
name: 'panda/tokens',
format: ({ dictionary }) => {
const tokens = {}
const semanticTokens = {}
for (const tok of dictionary.allTokens) {
const [group, ...rest] = tok.path
const isSemantic = !!tok.$extensions?.['ds.modes']
const target = isSemantic ? semanticTokens : tokens
const mapKey = ({
color: 'colors', spacing: 'spacing', radii: 'radii',
})[group] ?? group
target[mapKey] ??= {}
let node = target[mapKey]
for (let i = 0; i < rest.length - 1; i++) {
node[rest[i]] ??= {}
node = node[rest[i]]
}
if (isSemantic) {
const modes = tok.$extensions['ds.modes']
node[rest[rest.length - 1]] = {
value: {
base: `var(--${tok.path.join('-')})`,
_dark: `var(--${tok.path.join('-')})`, // 같은 변수, dark는 :root에서 override
},
}
} else {
node[rest[rest.length - 1]] = { value: `var(--${tok.path.join('-')})` }
}
}
return [
'// AUTO-GENERATED. DO NOT EDIT.',
`export const pandaTokens = ${JSON.stringify(tokens, null, 2)} as const`,
``,
`export const pandaSemanticTokens = ${JSON.stringify(semanticTokens, null, 2)} as const`,
``,
].join('\n')
},
})
// ───── Style Dictionary 설정
const sd = new StyleDictionary({
source: ['tokens/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'styles/',
files: [{
destination: 'tokens.css',
format: 'css/variables-with-modes',
}],
},
tailwind: {
transformGroup: 'js',
buildPath: 'generated/',
files: [{
destination: 'tailwind.preset.ts',
format: 'tailwind/preset',
}],
},
panda: {
transformGroup: 'js',
buildPath: 'generated/',
files: [{
destination: 'panda.tokens.ts',
format: 'panda/tokens',
}],
},
},
})
await sd.buildAllPlatforms()
console.log('✓ tokens generated')실행:
node build-tokens.mjs3) 생성된 출력 — styles/tokens.css
/**
* Do not edit directly, this file was auto-generated.
*/
@layer tokens {
:root {
--color-blue-50: oklch(0.97 0.02 250);
--color-blue-100: oklch(0.93 0.05 250);
--color-blue-500: oklch(0.62 0.20 250);
--color-blue-900: oklch(0.30 0.10 250);
--color-gray-50: oklch(0.98 0.00 250);
--color-gray-900: oklch(0.18 0.01 250);
--color-primary-500: var(--color-blue-500);
--color-bg-surface: var(--color-gray-50);
--color-text-default: var(--color-gray-900);
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-4: 1rem;
--spacing-8: 2rem;
--radii-md: 0.5rem;
--radii-lg: 0.75rem;
}
:root[data-theme="dark"] {
--color-primary-500: var(--color-blue-400);
--color-bg-surface: var(--color-gray-900);
--color-text-default: var(--color-gray-50);
}
}4) 생성된 출력 — generated/tailwind.preset.ts
// AUTO-GENERATED. DO NOT EDIT.
// Source: tokens/*.json
import type { Config } from 'tailwindcss'
export default {
theme: {
extend: {
colors: {
blue: {
50: 'var(--color-blue-50)',
100: 'var(--color-blue-100)',
500: 'var(--color-blue-500)',
900: 'var(--color-blue-900)',
},
gray: {
50: 'var(--color-gray-50)',
900: 'var(--color-gray-900)',
},
primary: {
500: 'var(--color-primary-500)',
},
bg: {
surface: 'var(--color-bg-surface)',
},
text: {
default: 'var(--color-text-default)',
},
},
spacing: {
1: 'var(--spacing-1)',
2: 'var(--spacing-2)',
4: 'var(--spacing-4)',
8: 'var(--spacing-8)',
},
borderRadius: {
md: 'var(--radii-md)',
lg: 'var(--radii-lg)',
},
},
},
} satisfies Partial<Config>5) 생성된 출력 — generated/panda.tokens.ts
// AUTO-GENERATED. DO NOT EDIT.
export const pandaTokens = {
colors: {
blue: {
50: { value: 'var(--color-blue-50)' },
100: { value: 'var(--color-blue-100)' },
500: { value: 'var(--color-blue-500)' },
900: { value: 'var(--color-blue-900)' },
},
gray: {
50: { value: 'var(--color-gray-50)' },
900: { value: 'var(--color-gray-900)' },
},
},
spacing: {
1: { value: 'var(--spacing-1)' },
2: { value: 'var(--spacing-2)' },
4: { value: 'var(--spacing-4)' },
8: { value: 'var(--spacing-8)' },
},
radii: {
md: { value: 'var(--radii-md)' },
lg: { value: 'var(--radii-lg)' },
},
} as const
export const pandaSemanticTokens = {
colors: {
primary: {
500: { value: { base: 'var(--color-primary-500)', _dark: 'var(--color-primary-500)' } },
},
bg: {
surface: { value: { base: 'var(--color-bg-surface)', _dark: 'var(--color-bg-surface)' } },
},
text: {
default: { value: { base: 'var(--color-text-default)', _dark: 'var(--color-text-default)' } },
},
},
} as const6) 소비 — 두 config가 모두 이 출력을 import
// tailwind.config.ts
import preset from './generated/tailwind.preset'
export default {
content: ['./app/**/*.{ts,tsx,mdx}', './styled-system/**/*.{ts,js}'],
presets: [preset],
} satisfies import('tailwindcss').Config// panda.config.ts
import { defineConfig } from '@pandacss/dev'
import { pandaTokens, pandaSemanticTokens } from './generated/panda.tokens'
export default defineConfig({
preflight: false,
include: ['./app/**/*.{ts,tsx}'],
outdir: 'styled-system',
theme: {
tokens: pandaTokens,
semanticTokens: pandaSemanticTokens,
},
})7) package.json 스크립트
{
"scripts": {
"tokens": "node build-tokens.mjs",
"prepanda": "pnpm tokens",
"panda": "panda codegen",
"predev": "pnpm tokens && pnpm panda",
"dev": "next dev",
"build": "pnpm tokens && pnpm panda && next build"
}
}8) 의존성
pnpm add -D style-dictionary@^4 @pandacss/dev tailwindcssStyle Dictionary v4(2024 release)는 ESM·async build API를 지원한다. v3는 동기 + CJS.
What — 다중 브랜드(Multi-brand) 확장
같은 파이프라인으로 여러 브랜드를 생성할 수도 있다.
tokens/
brand-a/
color.primitive.json (brand A: blue)
color.semantic.json
brand-b/
color.primitive.json (brand B: green)
color.semantic.json
shared/
spacing.json
radii.json// build-tokens.mjs 일부
const brands = ['brand-a', 'brand-b']
for (const brand of brands) {
const sd = new StyleDictionary({
source: [`tokens/${brand}/**/*.json`, 'tokens/shared/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
buildPath: `styles/${brand}/`,
files: [{ destination: 'tokens.css', format: 'css/variables-with-modes' }],
},
// ... tailwind/panda 동일하게 brand 폴더에 출력
},
})
await sd.buildAllPlatforms()
}각 브랜드의 CSS variables가 같은 이름을 갖되 다른 값을 갖는다. <html data-brand="b"> 로 런타임 브랜드 전환도 가능. 자세한 패턴은 06-theming.
What-if — 파이프라인이 깨지는 경우
- 함정 1: alias의 alias의 alias —
{color.primary.500}→{color.blue.500}→{color.base.500}처럼 깊어지면 Style Dictionary가 cycle 감지에서 멈춤. 권장: alias 깊이 최대 2. - 함정 2:
$type누락 —$type: "color"없이"value": "#3b82f6"만 있으면 Style Dictionary는 string으로 처리해 plaftform별 변환을 못 함. iOS UIColor 생성 실패. 항상$type명시. - 함정 3: 이름 충돌 —
color.primary와color.primary.500을 동시 정의하면 어떤 게 잎인지 모호. DTCG는 잎 노드만 토큰 — 부모는 그룹. - 함정 4: dark mode override를
$extensions가 아닌 별도 파일로 둠 —tokens.dark.json을 만들고 두 번 빌드하면 light/dark CSS 두 벌이 생성. 한 CSS에:root[data-theme=dark]로 들어가지 않음. 이 문서의ds.modesextension 패턴 권장. - 함정 5: 빌드 출력 commit 안 함 —
generated/를.gitignore에 넣으면 신규 PR마다pnpm tokens를 실행해야 CI 통과. 권장: commit해서 PR 리뷰에서 토큰 변경의 결과를 보이게 한다. - 함정 6: 두 도구의 prefix 차이로 변수 이름 충돌 — Style Dictionary가
--color-*를 만들었는데 Panda가--colors-*(s)를 기대. → Style Dictionary의transform을 커스터마이즈해 prefix 통일 (예:--ds-color-*).
Insight — Style Dictionary의 자리
Style Dictionary는 Amazon이 2016년에 내놓은 도구다. 당시 AWS 콘솔의 여러 팀이 같은 색을 다르게 정의하는 문제를 푸려고 만들어졌다. iOS·Android·웹·이메일 템플릿이 모두 같은 토큰을 봐야 했다.
2024년 v4가 나오면서 ESM + async API + plugin 생태계 정비로 de facto 표준에 다가섰다. 경쟁자 중 Theo(Salesforce)는 deprecated, cosmos(Auth0)는 내부용. 오픈소스 + plugin이 풍부한 것은 Style Dictionary뿐.
왜 Tokens Studio가 충분하지 않은가: Tokens Studio는 Figma plugin이고 JSON export까지만 책임진다. 플랫폼별 변환과 커스텀 출력은 별도 도구가 필요하며, Style Dictionary가 그 자리를 차지한다. 둘은 경쟁이 아니라 조합.
Tailwind v4의 @theme directive가 Style Dictionary를 죽이지 않는 이유: @theme은 CSS만 생성한다. Panda config·iOS Swift·Android XML은 여전히 별도 변환이 필요하다. SD는 멀티 플랫폼의 자리에 남는다.
흥미로운 반전: 본 챕터의 목적이 “Panda + Tailwind 공존”인데, 핵심 코드는 두 도구 어느 것도 import하지 않는다 — 오직 그들의 입력인 config 파일을 생성할 뿐. 이것이 진정한 SSOT 패턴의 모습이다.
요약
- DTCG JSON → Style Dictionary →
tokens.css+tailwind.preset.ts+panda.tokens.ts의 3출력 동시 생성. - 디자이너가 색 한 개를 바꾸면 세 출력 모두 빌드 시점에 갱신.
- 다크모드는
$extensions.ds.modes로 토큰에 동봉, CSS는:root[data-theme=dark]로 변수 override 생성. - Style Dictionary v4 + 커스텀
format이 핵심 — 도구는 얇고, 변환 규칙이 프로젝트의 자산.