콘텐츠로 이동

12. 디자인 토큰

“디자인 시스템을 도입한다”는 말의 90%는 토큰 이야기다

이 장은 4장(경계)·16장(자산화)과 함께 이 덱의 중심이다.

  • 토큰을 제대로 설계하면 → 테마 교체가 CSS 변수 몇 줄이 된다
  • 토큰 없이 만들면 → 디자인 변경이 전 파일 찾아 바꾸기가 된다
  • 17장에서 실제 디자인 시스템을 얹을 때 이 장의 구조를 그대로 쓴다

디자인 결정에 이름을 붙여 저장한 값이다.

/* 결정: 우리 브랜드의 주 색상은 이 색이다 */
--primary: oklch(0.55 0.22 264);
/* 결정: 기본 모서리 둥글기는 이 정도다 */
--radius: 0.5rem;

값이 아니라 결정을 저장한다는 게 핵심이다. #4f46e5는 값이고, --primary는 결정이다. 결정이 바뀌면 한 곳만 고친다.

토큰을 한 층으로 만들면 금방 무너진다. 실무에서는 세 층으로 나눈다.

flowchart TB
    A["1. 원시 토큰 (primitive)<br/>--indigo-600: oklch(0.51 0.23 277)<br/>순수한 값 · 의미 없음"]
    B["2. 의미 토큰 (semantic)<br/>--primary: var(--indigo-600)<br/>역할을 부여한다"]
    C["3. 컴포넌트 토큰 (component)<br/>--button-bg: var(--primary)<br/>특정 컴포넌트 전용"]

    A --> B --> C

    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    classDef key  fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    class A mute
    class B key
    class C warn

대부분의 프로젝트는 1·2단만 있으면 충분하다. 3단은 컴포넌트가 아주 많고 팀이 나뉘어 있을 때 필요해진다.

<button class="bg-zinc-900 text-zinc-50">
<div class="bg-zinc-900 text-zinc-50">
<span class="bg-zinc-900 text-zinc-50">

브랜드 색을 파랑으로 바꾸려면? → 전부 찾아서 바꿔야 한다.

그리고 이 중 어떤 게 “주요 버튼”이고 어떤 게 “그냥 어두운 배경”인지 구분이 안 된다. 찾아 바꾸기가 위험한 이유가 이것이다.

background / foreground 으로 이루어진 것이 핵심 규칙이다.

토큰 쌍 용도
background / foreground 앱 바탕, 기본 텍스트
card / card-foreground 카드처럼 떠 있는 표면
popover / popover-foreground 드롭다운, 툴팁 등 오버레이
primary / primary-foreground 주요 액션
secondary / secondary-foreground 보조 액션
muted / muted-foreground 흐린 배경, 설명 텍스트
accent / accent-foreground 호버·포커스 강조
destructive 삭제·오류
border / input / ring 테두리 / 입력 테두리 / 포커스 링
chart-1 ~ chart-5 차트 팔레트
sidebar-* 사이드바 전용 세트

바탕색을 정하면 그 위의 글자색이 따라온다. 이 짝이 대비를 보장한다.

flowchart LR
    P["--primary<br/>바탕"] --> PF["--primary-foreground<br/>그 위의 글자"]
    M["--muted<br/>바탕"] --> MF["--muted-foreground<br/>그 위의 글자"]
    PF --> R["항상 함께 쓴다<br/>bg-primary text-primary-foreground ✅"]
    MF --> R
    X["한쪽만 바꾸면<br/>대비가 깨진다 ❌"]

    classDef key  fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad  fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class P,M key
    class R ok
    class X bad
    class PF,MF mute

컴포넌트는 bg-primary text-primary-foreground항상 함께 쓴다.

여기가 실무에서 가장 자주 틀리는 지점이다. 값 정의유틸리티 생성은 다른 일이다.

1단계 — :root / .dark에 값을 정의한다

섹션 제목: “1단계 — :root / .dark에 값을 정의한다”
app/globals.css
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--border: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
/* … 같은 이름, 다른 값 */
}

2단계 — @theme inline으로 Tailwind에 연결한다

섹션 제목: “2단계 — @theme inline으로 Tailwind에 연결한다”

CSS 변수를 정의하는 것만으로는 bg-primary 유틸리티가 생기지 않는다.

@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
/* … 나머지 토큰 전부 */
}
flowchart LR
    A[":root / .dark<br/>--primary: oklch(...)<br/>값을 정의"] --> B["@theme inline<br/>--color-primary: var(--primary)<br/>유틸리티를 생성"]
    B --> C["컴포넌트<br/>class='bg-primary'"]

    classDef key  fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class A key
    class B ok
    class C mute
  • 왼쪽 — 무슨 값인가. 테마마다 다르다
  • 가운데 — 어떤 유틸리티를 만들 것인가. 한 번만 쓴다
  • 오른쪽 — 어떻게 쓰는가. 이름만 안다
  • 컴포넌트는 bg-background text-foreground만 쓴다
  • .dark 클래스가 붙으면 같은 이름의 변수 값만 바뀐다
  • 컴포넌트 코드에 dark: 변형이 거의 등장하지 않는다

라이트와 다크에서 마크업과 클래스는 완전히 동일하다. 바뀌는 것은 변수 값뿐이다.

색만 토큰이 아니다. 모서리 둥글기도 하나에서 파생시킨다.

@theme inline {
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); /* … 2xl까지 이어진다 */
}

--radius 하나를 0.125rem으로 바꾸면 앱 전체가 각져지고, 1.25rem으로 바꾸면 Material 스타일이 된다. 변수 한 줄로 앱의 인상이 바뀐다.

종류 비고
간격 --spacing v4는 이 하나에서 전체 스케일이 파생된다
타이포 --font-sans, --text-* 폰트 패밀리와 크기 스케일
모서리 --radius 파생 스케일
그림자 --shadow-* 고도(elevation) 표현
애니메이션 --animate-*, --ease-* 지속시간·이징
z-index 토큰화 권장 z-50, z-9999 난립 방지

z-index는 Tailwind 기본 스케일이 있지만, 모달·토스트·드롭다운의 층위는 프로젝트마다 정해야 한다. --z-modal: 50 식으로 명시해 두면 싸움이 줄어든다.

기본 토큰에 warning이 없다. 직접 추가해 보자.

  1. light 값을 정의한다

    :root {
    --warning: oklch(0.84 0.16 84);
    --warning-foreground: oklch(0.28 0.07 46);
    }
  2. dark 값을 정의한다 — 명도를 뒤집는다

    .dark {
    --warning: oklch(0.41 0.11 46);
    --warning-foreground: oklch(0.99 0.02 95);
    }
  3. @theme inline에 연결한다 — 이게 있어야 유틸리티가 생긴다

    @theme inline {
    --color-warning: var(--warning);
    --color-warning-foreground: var(--warning-foreground);
    }
  4. 쓴다

    <div class="bg-warning text-warning-foreground">주의</div>
  • 의미 없는 이름--color-1, --blue-2. 나중에 아무도 못 쓴다
  • 의미 층을 건너뜀 — 컴포넌트에서 bg-zinc-900 직접 사용
  • 너무 이른 3단 구조 — 컴포넌트 토큰을 처음부터 다 만들면 관리가 안 된다
  • -foreground 짝을 안 지킴 — 다크 모드에서 대비가 깨진다
  • @theme inline 연결 누락 — 변수는 있는데 유틸리티가 없다
  • 디자인 툴과 이름 불일치 — Figma는 Brand/Primary, 코드는 --accent
  • Figma Variables와 CSS 변수의 이름을 같게 만든다
  • 디자이너가 “primary를 바꿨어요”라고 하면 개발자가 바로 어디를 고칠지 안다
  • 자동화도 가능하다 — Figma API → Style Dictionary → CSS 변수 생성

자동화까지 안 가더라도 이름만 맞춰도 커뮤니케이션 비용이 크게 준다. “그 회색”이 아니라 “muted-foreground”라고 말하게 된다.

  • 토큰은 값이 아니라 결정에 이름을 붙인 것
  • 원시 → 의미 두 층이면 대부분 충분하다. 컴포넌트 토큰은 필요해질 때
  • shadcn/ui는 background/foreground으로 대비를 보장한다
  • :root/.dark에서 값 정의 → @theme inline에서 유틸리티 생성 두 단계
  • 다크 모드가 공짜인 이유: 이름은 그대로, 값만 바뀌기 때문
  • --radius 하나로 앱 전체 인상이 바뀐다
  • 토큰 추가는 light / dark / @theme inline 세 곳을 모두 건드린다