콘텐츠로 이동
Study Note프론트엔드

12. 디자인 토큰

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

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

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

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

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

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

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

원시 토큰 → 의미 토큰 → 컴포넌트 토큰 3단 계층

대부분의 프로젝트는 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-*사이드바 전용 세트

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

바탕 토큰과 foreground 토큰을 짝으로 함께 써야 대비가 유지된다

컴포넌트는 bg-primary text-primary-foreground를 항상 함께 쓴다. 네 짝을 실물 색으로 보면 —

primary
bg-primarytext-primary-foreground
secondary
bg-secondarytext-secondary-foreground
muted
bg-mutedtext-muted-foreground
destructive
bg-destructivetext-white

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

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);
/* … 나머지 토큰 전부 */
}
CSS 변수 정의에서 @theme inline 유틸리티 생성을 거쳐 컴포넌트 클래스로 이어지는 경로
  • 왼쪽 — 무슨 값인가. 테마마다 다르다
  • 가운데 — 어떤 유틸리티를 만들 것인가. 한 번만 쓴다
  • 오른쪽 — 어떻게 쓰는가. 이름만 안다
  • 컴포넌트는 bg-background text-foreground만 쓴다
  • .dark 클래스가 붙으면 같은 이름의 변수 값만 바뀐다
  • 컴포넌트 코드에 dark: 변형이 거의 등장하지 않는다

라이트와 다크에서 마크업과 클래스는 완전히 동일하다. 바뀌는 것은 변수 값뿐이다. 아래 두 카드는 같은 컴포넌트다 — 렌더 직전에 값만 갈렸다.

:root 값
로그인
계정으로 계속하기
.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 스타일이 된다. 변수 한 줄로 앱의 인상이 바뀐다.

--radius: 0.125rem
로그인
계정으로 계속하기
--radius: 0.5rem
로그인
계정으로 계속하기
--radius: 1.25rem
로그인
계정으로 계속하기

(색·폰트도 함께 바뀐 프리셋이지만, 모서리만 따라가며 봐도 인상 차이의 대부분이 radius다.)

종류예비고
간격--spacingv4는 이 하나에서 전체 스케일이 파생된다
타이포--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 세 곳을 모두 건드린다