🧩 Design System7. Panda × Tailwind 호환DTCG Tokens to Both — Style Dictionary로 양쪽 config 동시 생성

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단계

  1. 디자이너: Figma + Tokens Studio plugin이 DTCG JSON으로 export.
  2. Style Dictionary: JSON을 Token tree로 파싱 → 출력 포맷별 변환.
  3. 출력: 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.mjs

3) 생성된 출력 — 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 const

6) 소비 — 두 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 tailwindcss

Style 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.primarycolor.primary.500을 동시 정의하면 어떤 게 잎인지 모호. DTCG는 잎 노드만 토큰 — 부모는 그룹.
  • 함정 4: dark mode override를 $extensions가 아닌 별도 파일로 둠tokens.dark.json을 만들고 두 번 빌드하면 light/dark CSS 두 벌이 생성. 한 CSS에 :root[data-theme=dark]로 들어가지 않음. 이 문서의 ds.modes extension 패턴 권장.
  • 함정 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를 죽이지 않는 이유: @themeCSS만 생성한다. 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이 핵심 — 도구는 얇고, 변환 규칙이 프로젝트의 자산.