디자인 오픈소스로 디자인 시스템 따라 만들어보기 -디자인 토큰-

January 31, 2025

토큰을 통한 커뮤니케이션

디자인 오픈소스로 디자인 시스템 따라 만들어보기 -디자인 토큰-

개요

지난 포스팅에서는 디자인 시스템 라이브러리의 바탕이 되는 번들러와 각종 라이브러리를 세팅하는 방법을 알아보았다. 이번엔 디자인 시스템의 언어라고도 볼 수 있는 디자인 토큰에 대해 알아보고, 이를 추출하는 방법을 알아보려 한다.

디자인 토큰이란?

원티드 디자인 시스템의 컬러 토큰 중 일부
원티드 디자인 시스템의 컬러 토큰 중 일부
사용자에게 일관된 경험을 제공하는데에는 기술적인 부분만이 아닌 디자인 레벨에서 이뤄지는 통일성 또한 중요하다. 이러한 일관된 UX를 제공하기 위해서는 프로덕트 내에서 타이포그래피, 컬러, 레이아웃 등의 요소들을 규격화하여 프로덕트의 스타일을 구성할 수 있다. 이때, 규격화된 프로덕트의 유닛들은 곧 디자인 시스템의 단위이자 가장 작은 요소가 되는 디자인 토큰이 된다. 디자인 토큰을 바탕으로 어플리케이션의 디자인을 구축해나가면 규격 외의 디자인이 나오는 케이스를 방지하여 일관성 있고 정형화된 톤앤 매너를 가진 퀄리티 높은 디자인을 구축할 수 있다. 디자인 토큰은 이러한 프로덕트의 톤앤 매너를 규격화하는 것 뿐만 아니라 개발자와 디자이너의 커뮤니케이션을 돕는 역할도 한다. 예를 들어 디자인 토큰이 없는 경우에는 다음과 같은 상황이 발생할 수 있다.
디자이너: 프로덕트 내에 존재하는 #32a852 색상을 전체적으로 톤 다운시켜서 #317844으로 수정했어요.
이때 프론트엔드 개발자는 프로젝트 내에 존재하는 모든 #32a852 색상을 찾아서 #317844로 수정해야 한다. 커뮤니케이션 과정에서도 Hex 코드를 사용하여 수정사항을 공유하다 보니 직관적이지 않다는 단점 또한 존재한다. 그리고 만약 프로젝트에 동일한 색상이지만 표기법이 다른 rgbahsl 같은 표기법으로 작성된 코드가 존재한다면, 의도치 않은 변경사항 누락이 발생할 가능성 또한 존재한다. 만약 디자인 토큰이 존재한다면 위의 문제는 다음과 같이 해결된다.
프로덕트 내에 존재하는 #32a852 색상을 전체적으로 톤 다운시켜서 #317844으로 수정했어요. -> 컬러 토큰 중에 Green500 의 값을 좀 더 어둡게 #317844로 수정했어요.
이러한 수정사항을 반영할 경우 프론트엔드 개발자는 컬러 토큰 변수에 할당 된 값 단 하나만 수정하면 해당 컬러 토큰 변수를 참조하는 프로젝트 내 모든 색상이 일괄적으로 수정된다. 또한 색상 표기법에 비해 비교적 직관적인 커뮤니케이션이 가능하다. 또한 자주 사용하는 컬러들을 시맨틱 컬러로 분류하여 더욱 명시적으로 표현할 수 있고, 다양한 테마에서의 대응 또한 용이하다.

Token Studio

디자인 토큰을 관리하는 법은 개발적인 부분에서는 emotion이나 tailwindvanilla-extract 같은 라이브러리 별로 가지각색이지만, 디자인의 경우 피그마에선 주로 Variables를 사용하여 관리한다. 프로그래밍에서 변수를 통해 값을 관리하는 것처럼, 피그마에서도 Variable을 통해 색상, 타이포그래피, 레이아웃 등을 관리한다고 이해할 수 있다. 그렇다면 이제 개발자의 일은 디자인 시스템에서 관리하는 Variable을 추출하여 프로젝트에 적용하는 것이다. 이때 피그마에서 제공하는 Token Studio를 사용하여 간단하게 토큰의 동기화를 이뤄낼 수 있다.
Token Studio
Token Studio
Token Studio는 Figma에서 디자인 토큰을 생성, 관리, 배포할 수 있도록 도와주는 플러그인이다. 또한 프로젝트에서 설정된 토큰을 JSON 형태로 추출할 수 있도록 도와준다.

JSON을 토큰으로

원티드 디자인 시스템의 컬러 토큰을 추출하면 대략 다음과 같은 형태의 JSON 파일이 생성된다.
1// 팔레트 토큰 2{ 3 "Common": { 4 "0": { 5 "value": "#000000", 6 "type": "color" 7 }, 8 "100": { 9 "value": "#ffffff", 10 "type": "color" 11 } 12 }, 13 "Neutral": { 14 "5": { 15 "value": "#0f0f0f", 16 "type": "color" 17 }, 18 "10": { 19 "value": "#171717", 20 "type": "color" 21 }, 22 "15": { 23 "value": "#1c1c1c", 24 "type": "color" 25 }, 26 "20": { 27 "value": "#2a2a2a", 28 "type": "color" 29 }, 30 31 //... 32} 33 34// Semantic Light 토큰 35{ 36 "Primary": { 37 "Normal": { 38 "value": "{Blue.50}", 39 "type": "color" 40 }, 41 "Strong": { 42 "value": "{Blue.45}", 43 "type": "color" 44 }, 45 "Heavy": { 46 "value": "{Blue.40}", 47 "type": "color" 48 } 49 }, 50 "Label": { 51 "Normal": { 52 "value": "{Cool Neutral.10}", 53 "type": "color" 54 }, 55 "Strong": { 56 "value": "{Common.0}", 57 "type": "color" 58 } 59 }, 60 //... 61} 62 63// Semantic Dark 토큰 64{ 65 "Primary": { 66 "Normal": { 67 "value": "{Blue.60}", 68 "type": "color" 69 }, 70 "Strong": { 71 "value": "{Blue.55}", 72 "type": "color" 73 }, 74 "Heavy": { 75 "value": "{Blue.50}", 76 "type": "color" 77 } 78 }, 79 "Label": { 80 "Normal": { 81 "value": "{Cool Neutral.99}", 82 "type": "color" 83 }, 84 "Strong": { 85 "value": "{Common.100}", 86 "type": "color" 87 }, 88 } 89 90 //... 91} 92
이제 이런 JSON 기반 토큰을 Style Dictionary를 통해 CSS, SCSS, TypeScript 등의 언어로 변환할 수 있다. 혹은 직접 만들어도 된다. 이번 포스팅의 경우 Style Dictionary를 사용하는 대신 직접 스크립트를 구현하여 토큰을 추출하는 방법을 사용해보려 한다. 왜냐하면 Style Dictionary를 적용하였을때 Transform에서 HEX 코드의 Alpha 값을 추출하는 과정에서 이슈가 있었기 때문이다.

직접 만들기

1// 팔레트 토큰 2{ 3 "Common": { 4 "0": { 5 "value": "#000000", 6 "type": "color" 7 }, 8 "100": { 9 "value": "#ffffff", 10 "type": "color" 11 }, 12 //... 13 } 14} 15 16// Semantic Light 토큰 17{ 18 "Label": { 19 "Strong": { 20 "value": "{Common.0}", // 팔레트 토큰의 키를 참조 21 "type": "color" 22 }, 23 //... 24 } 25} 26 27// Semantic Dark 토큰 28{ 29 "Label": { 30 "Strong": { 31 "value": "{Common.100}", // 팔레트 토큰의 키를 참조 32 "type": "color" 33 }, 34 //... 35 } 36} 37
우선 위에서 본 JSON과 같이 색상 팔레트의 경우 정해진 값이 존재하지만, Semantic 컬러의 경우 라이트 테마, 다크 테마에 따라 팔레트 JSON의 키를 참조하는 것을 확인할 수 있다. 첫번째로 이러한 키를 참조하는 토큰을 팔레트로부터 값을 가져와 치환하는 스크립트를 작성해보자.

팔레트 토큰 치환

1// src/scripts/utils.js 2 3import fs from 'fs' 4 5export function resolveReferences(obj, palette) { 6 if (typeof obj === 'object' && obj !== null) { 7 for (const key in obj) { 8 if (typeof obj[key] === 'object') { 9 resolveReferences(obj[key], palette) 10 } else if ( 11 typeof obj[key] === 'string' && 12 obj[key].startsWith('{') && 13 obj[key].endsWith('}') 14 ) { 15 const refPath = obj[key].slice(1, -1).split('.') 16 let value = palette 17 for (const ref of refPath) { 18 value = value[ref] 19 } 20 obj[key] = value.value 21 } 22 } 23 } 24} 25 26export function parseJSON(path) { 27 return JSON.parse(fs.readFileSync(path, 'utf-8')) 28} 29 30export function writeJSON(path, json) { 31 fs.writeFileSync(path, JSON.stringify(json, null, 2), 'utf-8') 32} 33
코드를 설명하자면 JSON 관련 함수들은 말 그대로 JSON 파일을 읽고 쓰는 역할을 하며, resolveReferences 함수는 팔레트 토큰의 키를 참조하는 토큰을 팔레트 토큰의 값으로 치환하는 역할을 한다. parseJSON을 통해 읽어온 JSON Object를 재귀를 통해 순회하며 탐색하며 두번째 인자로 받은 Palette Object의 값으로 변환한다. 이제 이 함수를 실행할 script 파일을 작성해보자.
1// src/scripts/merge.js 2import { parseJSON, resolveReferences, writeJSON } from './utils.js' 3 4const PALETTE_JSON_FILE_PATH = 'src/json/Palette.json' 5const palette = parseJSON(PALETTE_JSON_FILE_PATH) 6 7const light = parseJSON('src/json/Light.json') 8const dark = parseJSON('src/json/Dark.json') 9 10resolveReferences(light, palette) 11resolveReferences(dark, palette) 12 13writeJSON('src/json/Light.json', light) 14writeJSON('src/json/Dark.json', dark) 15
이제 이 스크립트를 실행하면 팔레트 토큰의 값을 참조하는 토큰이 팔레트 토큰의 값으로 치환되며 기존의 JSON을 덮어씌우게 된다. package.json에 다음과 같이 스크립트를 추가해주면 쉽게 실행할 수 있다.
1// package.json 2{ 3 "scripts": { 4 "merge": "node src/scripts/merge.js" 5 } 6} 7
변경된 JSON
1// src/json/Light.json 2{ 3 "Primary": { 4 "Normal": { 5 "value": "#0066ff", // 팔레트 토큰의 키의 값 6 "type": "color" 7 }, 8 "Strong": { 9 "value": "#005eeb", 10 "type": "color" 11 }, 12 "Heavy": { 13 "value": "#0054d1", 14 "type": "color" 15 } 16 } 17 //... 18} 19

JSON To TypeScript

우리가 사용하는 vanilla-extract는 TypeScript 기반의 라이브러리이다. 따라서 JSON을 Typescript 변수로 변환하는 스크립트를 작성해보자.
1// src/scripts/generate.js 2import fs from 'fs' 3import path from 'path' 4 5const __dirname = path.resolve() 6 7const toPascalCase = (str) => { 8 return str 9 .replace(/[_\s]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : '')) 10 .replace(/^[a-z]/, (chr) => chr.toUpperCase()) 11} 12 13const generateTypeScript = (json, parentKey = '') => { 14 let tsContent = '' 15 16 for (const [key, value] of Object.entries(json)) { 17 const pascalKey = parentKey 18 ? `${parentKey}${toPascalCase(key)}` 19 : toPascalCase(key) 20 21 if (value.type === 'size') { 22 tsContent += `export const ${pascalKey.replaceAll('.', 'dot')} = '${value.value}';\n` 23 } 24 25 if (value.type === 'color') { 26 // For color type, generate export statement 27 tsContent += `export const ${pascalKey} = '${value.value}';\n` 28 } else if (typeof value === 'object' && !Array.isArray(value)) { 29 // Recurse if the value is an object 30 tsContent += generateTypeScript(value, pascalKey) 31 } 32 } 33 34 return tsContent 35} 36 37function generate(inputFilePath, outputFilePath) { 38 fs.readFile(inputFilePath, 'utf8', (err, data) => { 39 if (err) { 40 console.error('Error reading the input file:', err) 41 return 42 } 43 44 try { 45 const jsonData = JSON.parse(data) 46 const tsContent = generateTypeScript(jsonData) 47 48 if (outputFilePath === 'size.ts') { 49 console.log('tsContent') 50 console.log(jsonData) 51 } 52 53 fs.writeFile( 54 path.resolve(__dirname, 'src/variables', outputFilePath), 55 tsContent, 56 'utf8', 57 (err) => { 58 if (err) { 59 console.error('Error writing the output file:', err) 60 return 61 } 62 63 console.log( 64 'TypeScript file generated successfully at:', 65 outputFilePath, 66 ) 67 }, 68 ) 69 } catch (err) { 70 console.error('Error parsing JSON:', err) 71 } 72 }) 73} 74 75generate(path.resolve(__dirname, 'src/json/Palette.json'), 'palette.ts') 76generate(path.resolve(__dirname, 'src/json/Light.json'), 'light.ts') 77generate(path.resolve(__dirname, 'src/json/Dark.json'), 'dark.ts') 78generate(path.resolve(__dirname, 'src/json/Size.json'), 'size.ts') 79
코드의 흐름은 다음과 같다.
  1. Token JSON 파일을 읽어온다.
  2. 각 토큰의 값들을 const 변수로 변환하여 출력한다.
  3. 출력된 변수들을 TypeScript 파일로 저장한다.
이 또한 package.json에 다음과 같이 스크립트를 추가해주면 쉽게 실행할 수 있다.
1// package.json 2{ 3 "scripts": { 4 "generate": "node src/scripts/generate.js" 5 } 6} 7
생성된 TypeScript 파일은 다음과 같다.
1// src/variables/palette.ts 2export const Common0 = '#000000' 3export const Common100 = '#ffffff' 4export const Neutral5 = '#0f0f0f' 5export const Neutral10 = '#171717' 6export const Neutral15 = '#1c1c1c' 7export const Neutral20 = '#2a2a2a' 8export const Neutral22 = '#303030' 9export const Neutral30 = '#474747' 10export const Neutral40 = '#5c5c5c' 11export const Neutral50 = '#737373' 12//... 13 14// src/variables/light.ts 15// export const PrimaryNormal = '#3385ff'; 16export const PrimaryStrong = '#1a75ff' 17export const PrimaryHeavy = '#0066ff' 18export const LabelNormal = '#f7f7f8' 19export const LabelStrong = '#ffffff' 20export const LabelNeutral = '#c2c4c8e0' 21//... 22 23// src/variables/dark.ts 24// export const PrimaryNormal = '#0066ff'; 25export const PrimaryStrong = '#005eeb' 26export const PrimaryHeavy = '#0054d1' 27export const LabelNormal = '#171719' 28export const LabelStrong = '#000000' 29export const LabelNeutral = '#2e2f33e0' 30//... 31

vanilla-extract에 통합하기

이제 이러한 토큰을 vanilla-extract에 통합해보자. vanilla-extract는 CSS 변수를 생성하는 방법으로 여러 API를 제공하는데, 이번엔 SSR 환경이나 vanilla-extract가 아닌 환경에서도 사용하기 쉬운 createGlobalTheme API를 활용하여 구축해보자. createGlobalTheme API는 첫번째 인자로 입력된 CSS 셀렉터를 스코프로 설정하여 CSS 변수를 설정하고, css 변수를 JS 변수로 추출하는 역할을 한다. 또한 createGlobalThemeContract API를 통해 디자인 토큰에 대한 구현체를 생성하기 이전에 디자인 토큰에 대한 스키마를 미리 정의하여, 테마에 따른 동일한 토큰을 생성하여 여러 테마를 대응하는데 좋은 DX를 제공한다. 방법은 간단하다. index.css.ts 파일에서 다음과 같이 작성해주면 된다.
1// src/index.css.ts 2import { 3 createGlobalThemeContract, 4 createGlobalTheme, 5} from '@vanilla-extract/css' 6import { lightColors, darkColors, paletteColors } from '@repo/design-tokens' 7 8function generateContract<T extends object>(obj: T): T { 9 return Object.fromEntries(Object.entries(obj).map(([key]) => [key, key])) as T 10} 11 12const semanticContract = generateContract(lightColors) 13const paletteContract = generateContract(paletteColors) 14 15export const DARK_MODE_CLASS_NAME = 'dark-mode' 16 17function prefix(value: string | null) { 18 return `WDS-${value}` 19} 20 21export const semanticVars = createGlobalThemeContract(semanticContract, prefix) 22export const paletteVars = createGlobalThemeContract(paletteContract, prefix) 23 24createGlobalTheme('body', semanticVars, lightColors) 25createGlobalTheme(`body.${DARK_MODE_CLASS_NAME}`, semanticVars, darkColors) 26createGlobalTheme('body', paletteVars, paletteColors) 27 28export type SemanticColor = keyof typeof semanticVars 29export type PaletteColor = keyof typeof paletteVars 30export type Color = SemanticColor | PaletteColor 31
우선 generateContract 함수를 통해 Contract를 구성할 Object를 생성한다. vanilla-extract에서 CSS 변수 스키마의 기준이 되는 Contract Object는 Hex 코드 같은 값을 기본값으로 설정할 수 없기 때문에 값을 키와 동일하게 수정하는 작업을 진행한다. 이후 semanticContractpaletteContract를 통해 각각의 Contract를 생성하고, createGlobalThemeContract를 통해 각각의 토큰을 스키마로 정의한다. 이제 VE 내부에서는 createGlobalThemeContract가 반환한 값에 대해 다음과 같이 사용할 수 있다.
1const box = style({ 2 backgroundColor: semanticVars.PrimaryStrong, 3}) 4
이는 컴파일 시 다음과 같은 CSS로 변환된다.
1.box { 2 background-color: var(--WDS-Primary-Strong); 3} 4
이제 스키마의 조건을 충족하는 구현체를 설정한다. 방법은 간단하다. 애초에 Contract 자체가 토큰을 기반으로 재구성된 Object이니 이를 그대로 사용하면 된다.
1// src/index.css.ts 2createGlobalTheme('body', semanticVars, lightColors) 3createGlobalTheme(`body.${DARK_MODE_CLASS_NAME}`, semanticVars, darkColors) 4createGlobalTheme('body', paletteVars, paletteColors) 5
이제 실제 프로젝트에서 body 태그에 dark-mode 클래스를 추가하면 다크 테마에 대한 토큰이 적용되는 것을 확인할 수 있다.

Reference