Style Dictionary Pipeline
이 문서가 답하는 질문: 한 벌의 DTCG JSON을 어떻게 모든 플랫폼이 동시에 소비하는 코드로 변환하는가? Style Dictionary의 source / parser / transform / format / file은 정확히 무엇을 하는가. 한 줄 답 (Pyramid Top): Style Dictionary는 **“DTCG JSON을 입력으로 받아, 토큰별 transform 체인을 적용한 뒤 format 함수로 직렬화하여 platform별 파일을 emit하는 빌드 파이프라인”**이다.
Why — 왜 손으로 변환하지 않는가
토큰을 정의한 뒤 “각 플랫폼 코드는 사람이 받아쓰면 되지 않나?” 라는 유혹은 흔하다. 실제로 다음과 같이 깨진다.
| 시나리오 | 손 변환의 비용 | 자동화의 결과 |
|---|---|---|
| 색 50개 + light/dark | 100개 hex를 .scss, .ts, .swift, .xml에 4번 받아쓰기 | DTCG 한 번 수정 → 4개 파일 자동 갱신 |
#3b82f6을 OKLCH로 마이그레이션 | 4번 받아쓰기 + 검증 | transform 한 줄 추가 |
| 다크모드 토큰 추가 | 모든 출력 파일을 다시 손댐 | source 한 파일만 추가 |
| 새 플랫폼 추가 (Flutter) | 기존 토큰을 Flutter 문법으로 전수 변환 | platform 한 블록만 config에 추가 |
핵심은 **“이름은 사람이, 값의 형식 변환은 도구가”**다.
How — Style Dictionary의 5단 파이프라인
단계별 책임
| 단계 | 역할 | 예시 |
|---|---|---|
| source | 어떤 JSON 파일을 읽을지 | tokens/**/*.json |
| parser | JSON 객체를 token tree로 정규화 | DTCG $value → SD value |
| transform | 토큰 하나에 적용되는 변환들 | color.blue.500.value → rgb(59, 130, 246), name → color-blue-500 |
| format | token tree 전체를 한 파일의 텍스트로 직렬화 | CSS variables, TS export, XML 등 |
| file | 출력 경로·파일명·platform 매핑 | web/tokens.css, ios/Tokens.swift |
What — config.js 실전 예시
config.js (Style Dictionary v4, ESM):
import StyleDictionary from 'style-dictionary'
import { register } from '@tokens-studio/sd-transforms' // DTCG 호환 transform set
register(StyleDictionary)
export default {
source: ['tokens/**/*.json'],
preprocessors: ['tokens-studio'],
platforms: {
css: {
transformGroup: 'tokens-studio',
transforms: ['name/kebab'],
buildPath: 'build/web/',
files: [
{
destination: 'tokens.css',
format: 'css/variables',
options: { outputReferences: true }, // var(--color-blue-500) 유지
},
],
},
cssDark: {
transformGroup: 'tokens-studio',
transforms: ['name/kebab'],
buildPath: 'build/web/',
files: [
{
destination: 'tokens.dark.css',
format: 'css/variables',
filter: (t) => t.path[0] === 'color' && t.filePath.includes('dark'),
options: { selector: '[data-theme="dark"]', outputReferences: true },
},
],
},
ts: {
transformGroup: 'tokens-studio',
transforms: ['name/camel'],
buildPath: 'build/ts/',
files: [
{
destination: 'tokens.ts',
format: 'javascript/es6',
},
{
destination: 'tokens.d.ts',
format: 'typescript/es6-declarations',
},
],
},
tailwind: {
transformGroup: 'tokens-studio',
transforms: ['name/kebab'],
buildPath: 'build/tailwind/',
files: [
{
destination: 'preset.cjs',
format: 'custom/tailwind-preset', // 사용자 정의 format (아래)
},
],
},
panda: {
transformGroup: 'tokens-studio',
buildPath: 'build/panda/',
files: [
{
destination: 'tokens.ts',
format: 'custom/panda-tokens',
},
],
},
ios: {
transformGroup: 'ios-swift',
buildPath: 'build/ios/',
files: [
{
destination: 'Tokens.swift',
format: 'ios-swift/class.swift',
options: { className: 'Tokens' },
},
],
},
android: {
transformGroup: 'android',
buildPath: 'build/android/',
files: [
{
destination: 'colors.xml',
format: 'android/colors',
filter: { attributes: { category: 'color' } },
},
{
destination: 'dimens.xml',
format: 'android/dimens',
filter: { attributes: { category: 'size' } },
},
],
},
},
}빌드 실행:
npx style-dictionary build --config config.jsWhat — 각 플랫폼의 출력 예시
입력 — tokens/color.json
{
"color": {
"$type": "color",
"blue": {
"500": { "$value": "#3b82f6" }
},
"primary": {
"$value": "{color.blue.500}",
"$description": "Primary brand color"
}
}
}출력 1 — build/web/tokens.css
/**
* Do not edit directly.
* Generated on Tue, 19 May 2026 12:00:00 GMT
*/
:root {
--color-blue-500: #3b82f6;
--color-primary: var(--color-blue-500);
}outputReferences: true 덕에 --color-primary가 raw hex가 아닌 var(--color-blue-500)을 가리킨다 — 런타임 토글 가능.
출력 2 — build/ts/tokens.ts
/**
* Do not edit directly.
*/
export const ColorBlue500 = "#3b82f6"
export const ColorPrimary = "#3b82f6"
export const tokens = {
color: {
blue: { 500: ColorBlue500 },
primary: ColorPrimary,
},
} as const출력 3 — build/tailwind/preset.cjs (사용자 정의 format)
먼저 format 정의:
StyleDictionary.registerFormat({
name: 'custom/tailwind-preset',
format: ({ dictionary }) => {
const colors = {}
dictionary.allTokens
.filter((t) => t.path[0] === 'color')
.forEach((t) => {
const [, ...rest] = t.path
// color.blue.500 → colors.blue.500 = "var(--color-blue-500)"
let cursor = colors
rest.slice(0, -1).forEach((seg) => {
cursor[seg] = cursor[seg] ?? {}
cursor = cursor[seg]
})
cursor[rest.at(-1)] = `var(--${t.name})`
})
return `module.exports = ${JSON.stringify({ theme: { extend: { colors } } }, null, 2)}`
},
})출력:
module.exports = {
"theme": {
"extend": {
"colors": {
"blue": { "500": "var(--color-blue-500)" },
"primary": "var(--color-primary)"
}
}
}
}사용:
// tailwind.config.ts
import preset from './build/tailwind/preset.cjs'
export default { presets: [preset] }출력 4 — build/panda/tokens.ts
export const tokens = {
colors: {
blue: {
500: { value: '#3b82f6' },
},
},
}
export const semanticTokens = {
colors: {
primary: {
value: { base: '{colors.blue.500}', _dark: '{colors.blue.400}' },
},
},
}사용:
// panda.config.ts
import { tokens, semanticTokens } from './build/panda/tokens'
export default defineConfig({
theme: { tokens, semanticTokens },
})출력 5 — build/ios/Tokens.swift
import UIKit
public class Tokens {
public static let colorBlue500 = UIColor(red: 0.231, green: 0.510, blue: 0.965, alpha: 1.0)
public static let colorPrimary = UIColor(red: 0.231, green: 0.510, blue: 0.965, alpha: 1.0)
}출력 6 — build/android/colors.xml
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<color name="color_blue_500">#FF3B82F6</color>
<color name="color_primary">#FF3B82F6</color>
</resources>What — Transform의 작동 원리
transform은 토큰 하나에 적용되는 함수다. 4가지 종류:
| 종류 | 역할 | 예시 |
|---|---|---|
attribute | 메타데이터 추가 (token.attributes.category) | category 추론 |
name | 토큰 이름 변환 | color.blue.500 → color-blue-500 (kebab) |
value | 값 변환 | #3b82f6 → rgb(59, 130, 246) |
transitive | alias 풀린 후 적용 | 최종 값에 색 보정 |
내장 transform 예시
| 이름 | 역할 |
|---|---|
name/kebab | color.blue.500 → color-blue-500 |
name/camel | → colorBlue500 |
name/snake | → color_blue_500 |
color/hex | RGB 객체 → #rrggbb |
color/UIColor | hex → UIColor(red:..., green:..., blue:..., alpha:...) |
color/css | hex → #rrggbb 또는 rgba(...) |
size/rem | 16px → 1rem |
size/px | 1rem → 16px |
사용자 정의 transform 예
hex → OKLCH 변환 (OKLCH 도입 마이그레이션 시):
import { converter, formatCss } from 'culori'
const toOklch = converter('oklch')
StyleDictionary.registerTransform({
name: 'color/oklch',
type: 'value',
filter: (token) => token.$type === 'color' || token.attributes?.category === 'color',
transform: (token) => formatCss(toOklch(token.value)),
})transforms: ['color/oklch']를 platform에 추가하면 모든 색이 OKLCH로 출력.
What — 다크모드를 한 빌드로 처리하기
tokens/color.semantic.light.json + tokens/color.semantic.dark.json 두 파일을 두고 selector 옵션으로 분리:
platforms: {
cssLight: {
transformGroup: 'tokens-studio',
transforms: ['name/kebab'],
buildPath: 'build/web/',
files: [{
destination: 'tokens.light.css',
format: 'css/variables',
filter: (t) => !t.filePath.includes('dark'),
options: { selector: ':root, [data-theme="light"]', outputReferences: true },
}],
},
cssDark: {
transformGroup: 'tokens-studio',
transforms: ['name/kebab'],
buildPath: 'build/web/',
files: [{
destination: 'tokens.dark.css',
format: 'css/variables',
filter: (t) => t.filePath.includes('dark'),
options: { selector: '[data-theme="dark"]', outputReferences: true },
}],
},
}런타임:
<link rel="stylesheet" href="/build/web/tokens.light.css">
<link rel="stylesheet" href="/build/web/tokens.dark.css">
<html data-theme="light"> {/* JS로 토글 */}또는 한 파일에 두 selector를 합치는 themes plugin도 있음 (Style Dictionary v4 themes API).
What-if — 파이프라인이 깨지는 곳
함정 1: $type 누락으로 transform 미적용
color/hex transform은 attributes.category === 'color'인 토큰에만 적용된다. $type을 누락하면 filter에서 빠져 raw 값이 그대로 출력.
대응: 그룹 레벨에 $type 한 번. CI에서 $type 누락 검출.
함정 2: alias가 풀리지 않은 채 출력
outputReferences: false(기본값)면 --color-primary: #3b82f6로 hex가 박힌다. 런타임 다크모드 토글 실패.
대응: outputReferences: true. 단, 일부 format(특히 iOS/Android)은 지원 안 함 — 그 플랫폼은 alias 풀어서 박는 게 맞음.
함정 3: 한 plaform에 두 transformGroup
transformGroup: 'css' + transforms: ['color/oklch']를 동시에 쓰면 두 transform이 순서대로 적용. 어느 게 먼저인지 명시 안 하면 결과 달라짐.
대응: transformGroup 또는 transforms 둘 중 하나만. 명시적 transform 배열 권장.
함정 4: 파일 분할 시 reference 못 찾음
tokens/colors.json에서 tokens/components.json의 토큰을 alias로 참조하는데, source glob이 components를 안 잡으면 Reference doesn't exist.
대응: source glob을 항상 모든 토큰을 포함하게. 빌드 단계에서 전체를 한 dictionary로 합쳐서 처리.
함정 5: 출력 파일이 git에 체크인됨
build/ 폴더가 git에 들어가면 PR마다 자동 생성 파일 diff로 리뷰가 어려워짐.
대응: .gitignore에 build/ 추가. CI에서 빌드 후 artifact로 배포 또는 npm publish 시점에만 생성.
함정 6: Style Dictionary v3 → v4 마이그레이션
v3는 value(no $), v4는 $value 우선. 둘이 섞이면 어떤 토큰은 인식, 어떤 토큰은 무시.
대응: 마이그레이션 codemod 실행. @tokens-studio/sd-transforms의 preprocessor가 자동 변환.
Insight — Style Dictionary가 사실상 표준이 된 이유
2016년 Amazon이 내부 도구로 만든 Style Dictionary는, 2024년 현재 모든 주요 디자인 시스템의 빌드 백본이다. Salesforce, Adobe Spectrum, IBM Carbon, Material 3 — 거의 모두가 쓴다. 왜?
철학적으로:
- 플랫폼-중립 입력 + 플러그블 출력 — Unix 철학 (
tokens.json | sd build > anywhere) - transform 단위가 작음 — 사용자가 새 변환을 한 함수로 추가
- format은 그저 문자열 함수 — 어떤 출력 형식도 지원 가능
현실적으로:
- 표준 출력 형식이 수십 개 내장 (CSS, SCSS, Less, JS, TS, Swift, Objective-C, Java, Kotlin, Android XML, Compose, Flutter, JSON, JSON flat…)
- 활발한 커뮤니티 transform — OKLCH, Tailwind preset, Panda tokens, Tokens Studio 호환
- v4부터 DTCG 표준 1급 지원 —
$value·$type·alias를 native로 인식
경쟁자들의 운명:
- Theo (Salesforce): 자기 회사가 Style Dictionary로 갈아탐
- diez (Haiku Animator): 개발 중단
- Cosmos: 작은 niche
- Tokens Studio Build: Style Dictionary를 runtime으로 wrap
이런 흡수의 흐름은 표준화가 만드는 자연스러운 수렴이다. DTCG가 포맷 표준이라면, Style Dictionary는 그 포맷을 다루는 사실상의 reference implementation이다.
흥미로운 사실 하나: Style Dictionary의 이름은 농담 같다. “스타일 사전” — 너무 평범해서 검색이 안 된다는 푸념이 GitHub issue로 남아 있다. 하지만 이름이 평범해서 어떤 분야의 누가 봐도 의미를 안다는 것이, 표준이 되기 위한 흔하지 않은 조건이다.
요약
- Style Dictionary는 source → parser → transform → format → file의 5단 파이프라인.
- DTCG JSON 한 벌이 CSS variables / TS / Tailwind preset / Panda config / iOS Swift / Android XML로 동시 emit.
- v4부터 DTCG 1급 지원 —
$value·$type·alias 자동 인식. - 사용자 정의 transform·format으로 어떤 출력도 가능 (OKLCH, Flutter, SwiftUI…).
- 빌드 산출물은 git에 두지 말고 npm publish 또는 CI artifact로.