Style Dictionary `outputReferences`로 다크모드·브랜드 테마를 단 하나의 옵션으로 런타임 전환하는 법
메타 디스크립션: Style Dictionary v4의
outputReferences: true옵션과 CSS Custom Properties를 조합해, 다크모드와 멀티 브랜드 테마를 Semantic 계층 오버라이드만으로 런타임에 전환하는 실전 패턴을 다룹니다. CSS 변수와 JSON을 다뤄본 경험이면 충분합니다.
디자인 시스템을 구축하다 보면 어느 순간 반드시 부딪히는 벽이 있습니다. "다크 모드는 어떻게 관리하지?" 그리고 B2B 제품(고객사 브랜드를 입힌 별도 제품)이라면 여기에 "브랜드별 테마도 따로 관리해야 하나?"가 추가되죠. 처음엔 단순히 CSS 파일을 두 벌 만들면 되겠다고 생각했는데, 토큰이 수백 개를 넘어가는 순간 그 방법이 얼마나 끔찍한지 깨닫게 됩니다. 저도 그랬습니다. 토큰 237개를 두 파일에 손으로 복사하다가 sync가 깨진 순간, 뭔가 근본적으로 잘못됐다는 걸 실감했습니다.
이 글은 Style Dictionary의 outputReferences 옵션과 CSS Custom Properties의 조합으로 이 문제를 우아하게 해결하는 방법을 다룹니다. 이 패턴 하나로 다음 세 문제가 사라집니다. 첫째, 다크모드·브랜드 테마를 위해 토큰을 중복 선언하는 것. 둘째, 테마 파일끼리 sync가 깨지는 것. 셋째, 새 테마를 추가할 때마다 대규모 수정이 필요한 것. 토큰 파이프라인 하나로 다크모드·브랜드 테마를 런타임에 전환하면서도, 오버라이드 대상을 Semantic 계층으로 최소화하는 구조를 실무 코드 중심으로 살펴봅니다.
핵심 개념
outputReferences가 왜 테마 스위칭의 열쇠인가
Style Dictionary는 JSON으로 작성한 디자인 토큰을 CSS, Swift, XML 등 각 플랫폼 코드로 변환해주는 도구입니다. 기본 동작은 토큰 간 참조(alias)를 추적해서 최종 값으로 완전히 펼쳐 출력합니다. 그런데 여기서 outputReferences: true를 설정하면 동작이 달라집니다.
// tokens/color.tokens.json
{
"color": {
"red": { "$value": "#ff0000", "$type": "color" },
"danger": { "$value": "{color.red}", "$type": "color" }
}
}/* outputReferences: false (기본) — 참조가 해석되어 값으로 펼쳐짐 */
:root {
--color-red: #ff0000;
--color-danger: #ff0000;
}
/* outputReferences: true — var() 참조 체인이 그대로 유지됨 */
:root {
--color-red: #ff0000;
--color-danger: var(--color-red);
}두 번째 방식이 왜 중요하냐면, CSS Custom Properties는 브라우저 런타임에 계산되기 때문입니다. --color-danger가 var(--color-red)를 참조하는 구조라면, 나중에 다크 모드 CSS에서 --color-red만 바꿔도 --color-danger가 자동으로 따라 바뀝니다. 이 연쇄 반응이 런타임 테마 스위칭의 핵심 원리입니다.
3계층 토큰 구조: Primitive → Semantic → Component
디자인 토큰 계층 개념을 처음 접한다면: Primitive는 팔레트 원색, Semantic은 그 색에 의미를 부여한 것, Component는 UI 요소별 맥락을 나타냅니다.
솔직히 처음엔 3계층이 과하다고 느꼈는데, 멀티 테마를 다루다 보니 이 구조 없이는 불가능하다는 걸 바로 깨달았습니다.
| 계층 | 예시 | 역할 |
|---|---|---|
| Primitive | --color-red-500: #ef4444 |
실제 원시 값을 보유 |
| Semantic | --color-danger: var(--color-red-500) |
의미(위험) 부여 |
| Component | --button-bg: var(--color-danger) |
컴포넌트 맥락 적용 |
다크 모드 전환 시 건드려야 하는 계층은 Semantic뿐입니다. --color-danger가 var(--color-green-400)으로 바뀌면, Component 계층의 --button-bg는 자동으로 바뀐 값을 받습니다. Primitive와 Component는 손댈 필요가 없습니다.
왜 [data-theme="dark"]가 :root 선언을 이기냐고 궁금하실 수 있는데, CSS specificity 때문입니다. :root는 요소 선택자(0-0-1)인 반면, [data-theme="dark"]는 속성 선택자(0-1-0)라 우선순위가 더 높습니다. 덕분에 같은 변수명을 다시 선언해도 다크 모드 값이 안전하게 덮어씁니다.
W3C DTCG 스펙과 Style Dictionary v4
W3C Design Tokens Community Group이 디자인 토큰 명세 첫 번째 안정 버전을 발표했습니다. .tokens.json 확장자와 $value/$type 키가 공식화됐고, Style Dictionary v4는 이 형식을 네이티브로 지원합니다.
// DTCG 형식 (.tokens.json)
{
"color": {
"red": {
"$value": "#ff0000",
"$type": "color"
},
"danger": {
"$value": "{color.red}",
"$type": "color"
}
}
}v4에서 특히 주목할 변화는 outputReferences가 함수 형태를 지원하게 됐다는 점입니다. 기본 형태가 true/false 불리언이라면, 함수 형태는 토큰마다 조건부로 참조 출력 여부를 결정할 수 있는 시그니처입니다. outputReferencesFilter는 그 함수 형태를 활용한 공식 헬퍼 유틸로, 필터링된 토큰이 참조될 때 broken reference가 생기는 문제를 자동으로 처리해줍니다. 이 차이는 예시 3에서 자세히 다룹니다.
이제 이 개념들을 실제 코드에 적용해보겠습니다.
실전 적용
예시 1: 다크 모드 — 최소 오버라이드 패턴
가장 기본적이고 실무에서 많이 쓰이는 패턴입니다. 라이트 토큰을 :root에, 다크 토큰을 [data-theme="dark"] 셀렉터에 배치해 Semantic 계층만 덮어씁니다.
처음 이 패턴을 도입할 때 저는 Primitive 토큰도 다크 파일에 다시 선언하는 실수를 했습니다. --color-red-500은 라이트·다크 모두 동일한데 두 파일 모두에 넣어버린 거죠. global.json 하나에만 두고 Semantic만 덮어쓰는 게 핵심입니다.
샘플 토큰 파일:
// tokens/global.json — Primitive 토큰 (라이트·다크 공통)
{
"color": {
"red": { "$value": "#ef4444", "$type": "color" },
"green": { "$value": "#22c55e", "$type": "color" },
"gray": {
"900": { "$value": "#111827", "$type": "color" },
"100": { "$value": "#f3f4f6", "$type": "color" }
},
"white": { "$value": "#ffffff", "$type": "color" }
}
}// tokens/light.json — Semantic 토큰 (라이트 기본값)
{
"color": {
"bg": { "$value": "{color.white}", "$type": "color" },
"text": { "$value": "{color.gray.900}", "$type": "color" },
"danger": { "$value": "{color.red}", "$type": "color" }
}
}// tokens/dark.json — Semantic 토큰 (달라지는 것만)
{
"color": {
"bg": { "$value": "{color.gray.900}", "$type": "color" },
"text": { "$value": "{color.white}", "$type": "color" }
}
}토큰 파일 구조:
tokens/
├── global.json # Primitive 토큰 (공통)
├── light.json # 라이트 모드 Semantic 토큰
└── dark.json # 다크 모드 Semantic 토큰 (달라지는 것만)Style Dictionary 설정:
// style-dictionary.config.js (v4, ES Modules)
export default {
source: ['tokens/global.json', 'tokens/light.json', 'tokens/dark.json'],
platforms: {
css: {
transformGroup: 'css',
files: [
{
destination: 'dist/variables.css',
format: 'css/variables',
filter: token => !token.filePath.includes('dark'),
options: {
outputReferences: true,
selector: ':root',
},
},
{
destination: 'dist/variables-dark.css',
format: 'css/variables',
filter: token => token.filePath.includes('dark'),
options: {
outputReferences: true,
selector: '[data-theme="dark"], .dark',
},
},
],
},
},
};
filter가 자동으로 제외해주는 건지? — 네, 그렇습니다.filter: token => token.filePath.includes('dark')조건에 맞지 않는 토큰은 Style Dictionary가 해당 파일에서 아예 출력하지 않습니다.--color-danger처럼 다크에서 동일한 토큰은 수동으로 제거할 필요 없이 자동으로 빠집니다.
출력 결과:
/* dist/variables.css */
:root {
--color-red: #ef4444;
--color-gray-900: #111827;
--color-white: #ffffff;
/* Semantic — 라이트 기본값 */
--color-bg: var(--color-white);
--color-text: var(--color-gray-900);
--color-danger: var(--color-red);
}
/* dist/variables-dark.css */
[data-theme="dark"], .dark {
/* Semantic만 덮어씀 — Primitive는 그대로 재사용 */
--color-bg: var(--color-gray-900);
--color-text: var(--color-white);
/* --color-danger는 동일하므로 자동으로 제외됨 */
}| 포인트 | 설명 |
|---|---|
filter: token => token.filePath.includes('dark') |
다크 전용 토큰만 골라 파일 크기 최소화 |
outputReferences: true 양쪽 모두 |
var() 체인이 두 파일 모두에서 유지됨 |
| Primitive 중복 없음 | 다크 파일엔 달라지는 Semantic만 포함 |
런타임 전환 JavaScript:
// theme.js
const toggleTheme = (theme) => {
if (theme === 'dark') {
// 다크: 속성 추가 → [data-theme="dark"] 셀렉터 활성화
document.documentElement.setAttribute('data-theme', 'dark');
} else {
// 라이트: 속성 제거 → :root 기본값으로 복귀
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme', theme);
};
// 초기 로드: 시스템 설정 감지 + 사용자 오버라이드
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
const savedTheme = localStorage.getItem('theme') ?? (prefersDark.matches ? 'dark' : 'light');
toggleTheme(savedTheme);
// 시스템 설정 변경 감지
prefersDark.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
toggleTheme(e.matches ? 'dark' : 'light');
}
});왜
light값을 setAttribute하지 않나요? CSS 셀렉터가[data-theme="dark"]만 있으므로,data-theme="light"를 세팅해도 아무 효과가 없습니다. 라이트 모드일 땐 속성을 제거해서:root기본값으로 자연스럽게 복귀하는 게 더 깔끔합니다.
예시 2: 멀티 브랜드 테마 파이프라인
B2B SaaS나 화이트라벨 제품(고객사 브랜드를 입힌 별도 제품)이라면 브랜드별로 테마를 완전히 다르게 가져가야 할 때가 있습니다. 브랜드 × 모드 조합을 빌드 루프로 처리하는 패턴입니다.
토큰 구조:
tokens/
├── global/ # 공통 Primitive (모든 브랜드 공유)
│ └── color.json
├── brand-a/ # 브랜드 A Semantic 토큰
│ ├── light.json
│ └── dark.json
└── brand-b/ # 브랜드 B Semantic 토큰
├── light.json
└── dark.json빌드 스크립트:
// build.js
import StyleDictionary from 'style-dictionary';
const brands = ['brand-a', 'brand-b'];
const modes = ['light', 'dark'];
// 브랜드가 늘어도 selectors 객체만 추가하면 됩니다
const selectors = {
'brand-a': '.brand-a',
'brand-b': '.brand-b',
};
for (const brand of brands) {
for (const mode of modes) {
const sd = new StyleDictionary({
source: [
'tokens/global/**/*.json',
`tokens/${brand}/${mode}.json`,
],
platforms: {
css: {
transformGroup: 'css',
files: [{
destination: `dist/${brand}-${mode}.css`,
format: 'css/variables',
options: {
outputReferences: true,
selector: selectors[brand],
},
}],
},
},
});
await sd.buildAllPlatforms();
}
}HTML에서 테마 적용:
<!-- 브랜드 클래스를 루트에, data-theme으로 모드 전환 -->
<html class="brand-a">
<head>
<!-- 라이트를 기본으로 로드하고, 다크는 오버라이드로 추가 -->
<link rel="stylesheet" href="dist/brand-a-light.css">
<link rel="stylesheet" href="dist/brand-a-dark.css"
media="(prefers-color-scheme: dark)">
</head>
</html>두 파일을 모두 로드해야 하는 이유:
brand-a-dark.css만 링크하면 다크 모드일 때 라이트 변수가 아예 없어집니다. 라이트를 기본으로 깔고, 다크를media조건이나 JS로 오버라이드하는 방식이 안전합니다.
예시 3: 조건부 outputReferences 함수 (v4 전용)
실무에서 자주 맞닥뜨리는 상황인데, 필터로 일부 토큰을 제외하면 그 토큰을 참조하는 다른 토큰이 var(--undefined) 같은 broken 참조를 출력해버리는 문제가 있습니다.
v4에서 outputReferences는 불리언 외에 함수 시그니처도 받습니다:
// 함수 시그니처
outputReferences: (token: TransformedToken, options: { dictionary: Dictionary }) => booleanoutputReferencesFilter는 이 함수 형태를 활용한 공식 헬퍼로, "이 토큰이 참조하는 대상이 필터로 제거됐는가"를 자동으로 판단해 broken reference를 방지합니다.
import StyleDictionary, { outputReferencesFilter } from 'style-dictionary';
export default {
platforms: {
css: {
files: [{
destination: 'dist/variables.css',
format: 'css/variables',
filter: token => token.attributes.category !== 'internal',
options: {
// 필터링된 토큰을 참조하는 경우 var() 대신 해석된 값 사용
outputReferences: (token, { dictionary }) => {
return outputReferencesFilter(token, { dictionary });
},
},
}],
},
},
};예시 4: Tokens Studio + sd-transforms 연동
Figma Variables → Tokens Studio → Style Dictionary로 이어지는 풀 파이프라인이 업계 표준처럼 자리 잡았습니다.
// style-dictionary.config.js
import StyleDictionary from 'style-dictionary';
import { register } from '@tokens-studio/sd-transforms';
// Tokens Studio 전용 transforms 등록
register(StyleDictionary);
export default {
source: [
'tokens/$metadata.json',
'tokens/**/*.json',
],
preprocessors: ['tokens-studio'], // Tokens Studio 형식 전처리
platforms: {
css: {
transformGroup: 'tokens-studio', // 등록된 transform 그룹 사용
files: [{
destination: 'dist/variables.css',
format: 'css/variables',
options: { outputReferences: true },
}],
},
},
};장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 단일 진실 공급원 | JSON 토큰 파일 하나로 CSS·iOS·Android·JS 등 모든 플랫폼 동시 생성 |
| 최소 오버라이드 | Semantic 계층만 덮어써서 다크모드·브랜드 전환 — CSS 파일 크기 최소화 |
| 참조 체인 자동 계산 | var() 체인이 브라우저에서 런타임에 자동으로 계산됨 |
| 확장성 | 새 브랜드 테마 추가 = JSON 파일 하나 추가, 빌드 루프 객체에 항목 하나 추가 |
| 디자이너-개발자 협업 | Figma Variables → Tokens Studio → Style Dictionary 파이프라인 자동화 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 설정 복잡도 | 멀티 브랜드 + 멀티 모드 조합은 공식 문서만으로 구현하기 어려움 | 이 글의 예시 2를 템플릿으로 활용 |
| 필터 + outputReferences 충돌 | 필터링된 토큰 참조 시 var(--undefined) 출력 위험 |
v4 outputReferencesFilter 유틸 적용 |
light-dark() 생성 한계 |
DTCG 모드 구조와 충돌, 단일 토큰에서 양방향 값 접근 어려움 | 커스텀 포맷터 작성 또는 Terrazzo 검토 |
| 번들 크기 증가 | 브랜드 × 모드 조합이 늘수록 CSS 파일 수 증가 | <link media> 또는 동적 import 조건부 로딩 전략 설계 |
| 빌드 시간 | 수천 개 토큰 + 멀티 플랫폼에서 빌드 지연 가능 | 증분 빌드 또는 브랜드별 병렬 빌드 적용 |
CSS
light-dark()함수 —color: light-dark(#000, #fff)형태로 라이트/다크 값을 한 줄에 선언하는 네이티브 CSS 함수입니다. 주요 브라우저에 모두 지원되지만, Style Dictionary의 DTCG 모드 구조와 통합하려면 라이트·다크 값을 동시에 접근하는 커스텀 포맷터가 필요합니다.
CSS
color-scheme프로퍼티 —:root { color-scheme: light dark; }를 선언하면 브라우저가 스크롤바·폼 요소 등 기본 UI도 자동으로 다크 모드로 렌더링합니다.outputReferences파이프라인과 함께 쓰는 것을 권장합니다.
실무에서 가장 흔한 실수
-
Primitive 계층을 다크 파일에 다시 선언하는 것 —
--color-red-500은 라이트·다크 모두 동일하므로global.json하나에만 두고 Semantic만 덮어씁니다. 중복 선언하면 파일 크기만 늘어납니다. -
outputReferences를 빼먹는 것 — 이 옵션 없이는 모든 참조가 최종 값으로 펼쳐지고, 다크 CSS에서 루트 변수를 바꿔도 연쇄 반응이 일어나지 않습니다. 테마 전환이 아예 동작하지 않는 가장 흔한 원인입니다. -
팀 전체가 3계층 규칙을 지키지 않는 것 — 누군가 Component 토큰에서 Primitive를 직접 참조(
--button-bg: var(--color-red-500))하면, 다크 모드에서 그 버튼만 테마가 적용되지 않습니다. Semantic을 반드시 거치는 컨벤션을 팀 lint 규칙이나 리뷰 체크리스트로 강제하는 것을 권장합니다.
마치며
지금까지 살펴본 것을 정리해보면, 핵심은 세 가지입니다. outputReferences: true로 var() 참조 체인을 살려두고, Primitive·Semantic·Component 3계층으로 토큰을 분리하고, 다크 파일엔 달라지는 Semantic만 담는 것. 이 세 가지가 맞물리면 새 테마를 추가하는 일이 JSON 파일 하나를 더하는 작업으로 줄어드는 경험을 하게 됩니다.
outputReferences: true 하나로 토큰 참조 체인을 살려두면, 다크모드와 브랜드 테마 전환이 Semantic 계층 오버라이드만으로 완성됩니다. 토큰이 수천 개를 넘어가는 규모라면 더더욱 이 구조가 빛을 발합니다. 반나절이면 기본 파이프라인이 돌아가고, 그 이후부터는 JSON 파일만 추가하면 됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
Style Dictionary v4 설치 및 기본 구조 세팅 —
pnpm add -D style-dictionary로 설치 후,tokens/global.json(Primitive)과tokens/light.json(Semantic) 두 파일부터 작성해보시면 좋습니다. 처음부터 DTCG 형식($value,$type)을 쓰는 것을 권장합니다. 나중에 Tokens Studio 연동 시 포맷 전환 비용이 없기 때문입니다. -
outputReferences: true옵션으로 CSS 변수 파일 생성 확인 — 빌드 후 출력된 CSS에서var(--color-xxx)형태의 참조가 살아있는지 확인하시면 됩니다. 값으로 펼쳐졌다면 옵션이 적용되지 않은 것입니다. -
tokens/dark.json추가 및 다크 모드 CSS 생성 — Semantic 토큰 중 달라지는 것만dark.json에 정의하고,filter로 해당 파일의 토큰만 골라[data-theme="dark"]셀렉터로 출력해보시면 됩니다.data-theme속성을 토글하면 테마가 전환되는 것을 확인할 수 있습니다.
막히는 부분이 생기면 Style Dictionary 공식 Discord나 GitHub Discussions에서 질문해보시면 커뮤니티가 꽤 빠르게 답변해줍니다.
다음 글: Style Dictionary 커스텀 포맷터를 작성해 CSS
light-dark()함수를 자동 생성하고, DTCG 모드(modes) 구조와 통합하는 방법을 다룰 예정입니다.
참고 자료
- Dark Mode with Style Dictionary | dbanks design
- Implementing Light and Dark Mode with Style Dictionary (Part 1) | Always Twisted
- Implementing Light and Dark Mode with Style Dictionary (Part 2) | Always Twisted
- Formats | Style Dictionary 공식 문서
- References | Style Dictionary 공식 문서
- Migration Guidelines v4 | Style Dictionary
- Examples | Style Dictionary
- Style Dictionary V4 release plans | Tokens Studio
- sd-transforms | Tokens Studio GitHub
- Design Tokens specification reaches first stable version | W3C Community Group
- The developer's guide to design tokens and CSS variables | Penpot
- Output References | DeepWiki amzn/style-dictionary
- Advanced Theming Techniques with Design Tokens | Medium
- Building a Scalable Design Token System | Medium