Tailwind에서 디자인 토큰을 유지하는 방법

Tailwind에서 디자인 토큰을 유지하는 방법

한눈에 보기

Tailwind를 사용한다고 디자인 일관성이 자동으로 생기지는 않는다. bg-[#1677ff], p-[18px] 같은 임의값을 화면마다 쓰면 이름 없는 CSS가 class 문자열로 이동할 뿐이다. 원시 값, 의미 토큰, 컴포넌트 variant를 분리하고 utility가 토큰을 소비하도록 만들면 테마 변경과 디자인 리뷰의 범위를 통제할 수 있다.

Tailwind의 장점은 UI를 빠르게 조립할 수 있다는 것이다.

<button className="rounded-lg bg-blue-600 px-4 py-2 text-white">
  저장
</button>

문제는 비슷한 버튼이 늘어날 때 시작된다.

<button className="rounded-[9px] bg-[#1769e0] px-[18px] py-2.5">
  저장
</button>

<button className="rounded-md bg-[#1677ff] px-5 py-[9px]">
  계속
</button>

두 화면이 의도적으로 다른지, 개발자가 눈대중으로 비슷한 값을 고른 것인지 알기 어렵다. 브랜드 색을 바꾸려면 hex 값과 유사한 palette class를 저장소 전체에서 찾아야 한다.

디자인 토큰은 단순한 상수 모음이 아니다. 제품에서 반복되는 시각적 결정을 이름으로 표현하고, 변경의 영향을 추적할 수 있게 만드는 계약이다.

목차

Utility-first와 디자인 시스템은 충돌하지 않는다

Utility-first는 CSS 선언을 작은 class로 조합하는 작성 방식이고, 디자인 토큰은 어떤 값을 사용할지 정하는 체계다. 서로 다른 층의 문제다.

flowchart LR
    D["디자인 결정"] --> T["토큰"]
    T --> U["Tailwind utility"]
    U --> V["컴포넌트 variant"]
    V --> P["제품 화면"]

bg-blue-600도 Tailwind가 제공하는 색상 theme variable을 사용하는 utility다. 문제는 utility 사용 자체가 아니라 제품 의미가 raw palette 단계에만 머무는 것이다.

<div className="border-zinc-200 bg-white text-zinc-950">
  ...
</div>

이 class는 현재 색을 분명하게 보여 주지만 역할은 설명하지 않는다. 다크 모드나 브랜드 변경 때 화면마다 dark:bg-zinc-900을 반복하면 상태 조합이 분산된다.

제품 의미를 토큰으로 만들면 UI 코드는 역할을 소비한다.

<div className="border-subtle bg-surface text-primary">
  ...
</div>

surface, primary, subtle의 실제 색은 테마에서 결정된다. 개발자는 class를 읽고 역할을 이해하며 디자이너는 토큰 변경의 영향을 예측할 수 있다.

의미 이름도 만능은 아니다

primary가 브랜드 색인지 본문 텍스트인지 애매할 수 있다. 팀 안에서 namespace와 용어집을 정하고, 이름만으로 역할과 사용 범위를 설명할 수 있게 한다.

토큰을 세 계층으로 나누기

토큰을 한 계층에 모두 넣으면 재사용과 의미가 섞인다. 다음 세 층으로 나누면 변경 이유가 선명해진다.

1. 원시 토큰

색상 scale, 간격, radius처럼 디자인의 재료가 되는 값이다.

--brand-500: oklch(0.61 0.2 255);
--brand-600: oklch(0.53 0.2 255);
--gray-50: oklch(0.98 0.004 260);
--gray-900: oklch(0.22 0.02 260);

2. 의미 토큰

어디에 사용하는지를 나타낸다.

--app-surface: var(--gray-50);
--app-text-primary: var(--gray-900);
--app-action: var(--brand-600);
--app-focus-ring: var(--brand-500);

3. 컴포넌트 토큰 또는 variant

특정 컴포넌트의 상태와 크기를 표현한다.

--button-primary-bg: var(--app-action);
--button-radius: var(--radius-control);
--button-height-md: 2.75rem;

관계는 다음과 같다.

brand-600
   └── action
         ├── button-primary-background
         ├── link-color
         └── selected-border

모든 프로젝트에 컴포넌트 토큰이 필요한 것은 아니다. 작은 서비스라면 원시 토큰과 의미 토큰만으로 충분할 수 있다. 같은 컴포넌트가 여러 브랜드·플랫폼에서 다른 규칙을 가져야 할 때 세 번째 계층의 가치가 커진다.

계층 변경 이유 이름 예 직접 사용하는 곳
원시 palette나 scale 조정 brand-600, space-4 주로 토큰 정의
의미 제품 역할·테마 변경 surface, action utility와 컴포넌트
컴포넌트 특정 UI 계약 변경 button-primary-bg 해당 컴포넌트

Tailwind v4 theme variable 이해하기

Tailwind v4에서는 @theme으로 utility API에 연결되는 theme variable을 정의할 수 있다.

@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.61 0.2 255);
  --color-brand-600: oklch(0.53 0.2 255);
  --radius-control: 0.5rem;
  --shadow-card: 0 8px 24px rgb(15 23 42 / 0.08);
}

namespace는 생성되는 utility와 연결된다.

<button className="rounded-control bg-brand-600 shadow-card">
  저장
</button>

대표 namespace는 다음과 같다.

namespace 연결되는 utility 예
--color-* bg-*, text-*, border-*
--font-* font-*
--text-* text-* 크기
--spacing-* padding, margin, width, height
--radius-* rounded-*
--shadow-* shadow-*
--breakpoint-* responsive variant
--container-* container query와 크기

일반 CSS custom property와 @theme variable은 목적이 다르다.

:root {
  --chart-axis-opacity: 0.42;
}

모든 CSS 변수를 @theme에 넣으면 사용하지 않을 utility namespace가 제품의 공개 API처럼 늘어난다.

의미 토큰으로 테마 전환하기

light와 dark theme에서 같은 의미 class를 유지하려면 일반 CSS 변수에 실제 값을 매핑하고 Tailwind에 그 변수를 연결할 수 있다.

@import "tailwindcss";

:root {
  --app-surface: oklch(0.99 0.002 260);
  --app-surface-raised: oklch(1 0 0);
  --app-text-primary: oklch(0.22 0.02 260);
  --app-text-muted: oklch(0.5 0.02 260);
  --app-border-subtle: oklch(0.9 0.01 260);
  --app-action: oklch(0.53 0.2 255);
}

[data-theme="dark"] {
  --app-surface: oklch(0.18 0.015 260);
  --app-surface-raised: oklch(0.23 0.015 260);
  --app-text-primary: oklch(0.95 0.005 260);
  --app-text-muted: oklch(0.72 0.015 260);
  --app-border-subtle: oklch(0.34 0.02 260);
  --app-action: oklch(0.7 0.16 255);
}

@theme inline {
  --color-surface: var(--app-surface);
  --color-surface-raised: var(--app-surface-raised);
  --color-primary: var(--app-text-primary);
  --color-muted: var(--app-text-muted);
  --color-subtle: var(--app-border-subtle);
  --color-action: var(--app-action);
}

컴포넌트에서는 theme마다 class를 다시 나열하지 않는다.

export function SettingsCard() {
  return (
    <section className="border border-subtle bg-surface-raised text-primary">
      <h2>알림 설정</h2>
      <p className="text-muted">
        이메일 알림 수신 여부를 관리합니다.
      </p>
      <button className="bg-action text-white">
        저장
      </button>
    </section>
  );
}

theme 변경은 변수 값에서 일어나고 컴포넌트는 같은 의미를 유지한다.

이 방식의 장점은 색 하나를 여러 상태에 무조건 공유하지 않게 한다는 것이다. light theme에서 action 배경과 focus ring이 같은 raw 색이어도 dark theme에서는 대비 때문에 다른 값이 필요할 수 있다.

:root {
  --app-action: var(--brand-600);
  --app-focus-ring: var(--brand-500);
}

[data-theme="dark"] {
  --app-action: var(--brand-500);
  --app-focus-ring: var(--brand-300);
}

토큰 이름은 같게 유지하면서 theme별 접근성 요구를 만족할 수 있다.

컴포넌트 variant가 토큰을 소비하게 하기

토큰이 있어도 컴포넌트마다 class 문자열을 복사하면 상태 누락과 조합 충돌이 생긴다.

<button className="bg-action text-on-action ...">저장</button>
<button className="bg-danger text-on-danger ...">삭제</button>

반복되는 UI는 variant API를 만든다. 아래 코드는 특정 저장소가 아닌 구조 설명용 예시다.

import { cva, type VariantProps } from "class-variance-authority";

const buttonStyles = cva(
  [
    "inline-flex items-center justify-center",
    "rounded-control font-medium",
    "focus-visible:outline focus-visible:outline-2",
    "focus-visible:outline-offset-2",
    "disabled:pointer-events-none disabled:opacity-50",
  ],
  {
    variants: {
      intent: {
        primary:
          "bg-action text-on-action hover:bg-action-hover",
        danger:
          "bg-danger text-on-danger hover:bg-danger-hover",
        quiet:
          "bg-transparent text-primary hover:bg-surface-muted",
      },
      size: {
        sm: "h-9 px-3 text-sm",
        md: "h-11 px-4 text-base",
      },
    },
    defaultVariants: {
      intent: "primary",
      size: "md",
    },
  },
);

type ButtonProps =
  React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonStyles>;

variant는 허용된 조합을 한 곳에 모으고, 토큰은 실제 값과 theme를 담당한다.

flowchart LR
    T["의미 토큰
action, danger"] --> U["Tailwind utility"] U --> V["Button variant
primary, danger"] V --> C["제품 코드
intent='danger'"]

제품 화면에서는 “red 600”이 아니라 “danger”를 선택한다.

<Button intent="danger" size="md">
  계정 삭제
</Button>

class 병합 도구를 사용하더라도 소비자가 임의로 핵심 variant를 깨뜨릴 수 있는지 계약을 정해야 한다. 완전한 자유가 필요한 primitive와 디자인 규칙을 강제할 product component를 구분한다.

간격과 크기 토큰은 어떻게 정할까

색상만 토큰화하고 간격에 mt-[13px], gap-[19px]가 늘어나는 경우가 많다. 모든 길이를 하나의 scale에 억지로 맞출 필요는 없지만 반복되는 리듬은 이름을 가져야 한다.

@theme {
  --spacing-control-x: 1rem;
  --spacing-control-y: 0.625rem;
  --spacing-section: 2rem;
  --radius-control: 0.5rem;
  --radius-surface: 0.75rem;
}

다만 Tailwind의 spacing namespace는 여러 sizing utility에 영향을 줄 수 있다. control-x처럼 의미 이름이 어떤 utility 조합을 생성하고 팀이 이를 어떻게 사용할지 확인한다. 특정 컴포넌트에서만 필요한 값은 일반 CSS 변수나 variant class 안에 두는 편이 더 나을 수 있다.

Fluid typography와 spacing도 토큰으로 표현할 수 있다.

@theme {
  --text-page-title: clamp(1.75rem, 4vw, 3rem);
  --spacing-page-gutter: clamp(1rem, 3vw, 2rem);
}

토큰 후보는 다음 기준으로 고른다.

한 번만 쓰는 illustration 좌표까지 모두 전역 토큰으로 만들면 오히려 목록이 오염된다.

임의값을 예외로 관리하기

Tailwind의 arbitrary value는 필요한 기능이다.

<div className="top-[117px]" />
<div className="grid-cols-[1fr_20rem]" />

문제는 문법이 아니라 반복되는 디자인 결정이 이름 없이 남는 것이다.

임의값을 세 종류로 나누면 관리하기 쉽다.

종류 처리
콘텐츠·알고리즘 고유 값 grid-cols-[minmax(0,1fr)_20rem] 그대로 사용 가능
일회성 시각 위치 illustration의 top-[117px] 컴포넌트 안에 격리
반복되는 제품 값 여러 화면의 bg-[#1677ff] 토큰 승격

검색으로 반복을 찾을 수 있다.

rg -o "\\[[^]]+\\]" src |
  sort |
  uniq -c |
  sort -nr

이 결과는 자동 금지 목록이 아니다. 높은 빈도의 arbitrary color, spacing, radius를 디자인 토큰 후보로 검토하는 자료다.

리뷰 규칙을 다음처럼 둘 수 있다.

17px을 전부 16px로 바꾸는 것이 항상 정답은 아니다. 차이가 의도인지 확인하고, 의도라면 역할 이름을 부여한다.

상태와 접근성 토큰 설계하기

기본 배경과 텍스트 색만 토큰화하면 hover, focus, disabled, error에서 raw palette가 다시 등장한다.

:root {
  --app-action: oklch(0.53 0.2 255);
  --app-action-hover: oklch(0.47 0.19 255);
  --app-on-action: white;
  --app-danger: oklch(0.5 0.2 25);
  --app-on-danger: white;
  --app-focus-ring: oklch(0.65 0.18 255);
  --app-disabled-bg: oklch(0.9 0.01 260);
  --app-disabled-text: oklch(0.5 0.01 260);
}

상태 토큰은 단순히 같은 색의 명도를 한 단계 바꾸는 규칙보다 실제 대비와 인지 가능성을 기준으로 검증한다.

<input
  className={[
    "border-subtle bg-surface text-primary",
    "focus-visible:outline-focus-ring",
    "aria-invalid:border-danger",
  ].join(" ")}
  aria-invalid={hasError}
  aria-describedby={hasError ? "email-error" : undefined}
/>

시각 상태와 실제 접근성 상태가 함께 변해야 한다. border-danger만 추가하고 aria-invalid나 오류 설명을 누락하면 토큰 체계는 좋아져도 사용 경험은 완성되지 않는다.

v3 설정과 v4 방식을 혼동하지 않기

기존 프로젝트나 오래된 글에서는 JavaScript 설정 파일을 사용한다.

export default {
  theme: {
    extend: {
      colors: {
        brand: {
          500: "#3b82f6",
          600: "#2563eb",
        },
      },
      borderRadius: {
        control: "0.5rem",
      },
    },
  },
};

이 형식은 Tailwind v3 계열 프로젝트에서 흔하다. v4의 CSS-first @theme 예시와 같은 파일에 무작정 섞지 않는다.

항목 v3 중심 프로젝트 v4 중심 프로젝트
theme 확장 tailwind.config.js/ts CSS의 @theme
토큰 노출 config가 utility 생성 theme variable이 utility와 CSS 변수 생성
런타임 theme CSS custom property 조합 CSS custom property와 @theme inline

마이그레이션 여부는 “최신 문법이 더 예뻐서”가 아니라 플러그인 호환성, 빌드 도구, 브라우저 대상, 기존 preset을 기준으로 결정한다. 블로그 예시에도 대상 버전을 명시해야 독자가 자신의 프로젝트에 맞게 판단할 수 있다.

이 글의 주 예시는 v4 기준이다

토큰의 계층과 이름 짓기 원칙은 버전과 무관하지만 선언 문법은 다르다. 발행 시점 공식 문서와 프로젝트의 설치 버전을 확인한다.

토큰 변경을 안전하게 운영하기

토큰은 영향 범위가 넓기 때문에 작은 값 변경도 여러 화면을 바꾼다. 이를 장점으로 만들려면 운영 절차가 필요하다.

소유자와 설명을 기록한다

color.action:
  purpose: 주요 행동 배경
  owner: design-system
  themes:
    - light
    - dark
  consumers:
    - Button.primary
    - Link.emphasis

실제 비밀이나 런타임 설정이 아니라 디자인 토큰 문서화를 위한 예시다.

시각 회귀 범위를 만든다

토큰을 사용하는 컴포넌트의 기본·hover·focus·disabled·error 상태를 한 화면에 모은다. light/dark theme와 주요 breakpoint를 함께 캡처하면 변경 영향을 확인하기 쉽다.

사용하지 않는 토큰을 제거한다

이름만 남고 소비자가 없는 deprecated 토큰은 새 코드에서 다시 발견될 수 있다. 검색과 정적 분석으로 consumer를 확인하고 단계적으로 제거한다.

이름 변경은 값 변경과 분리한다

brandaction으로 이름만 바꾸는 작업과 실제 색상 변경을 한 PR에 섞으면 시각 회귀 원인을 찾기 어렵다. 구조 변경과 디자인 변경을 가능한 한 분리한다.

토큰을 API로 본다

공유 패키지의 토큰 이름을 바꾸면 여러 애플리케이션에 영향을 준다. deprecated alias 기간, 변경 로그, codemod 여부를 결정한다.

flowchart LR
    A["새 토큰 추가"] --> B["컴포넌트 소비자 전환"]
    B --> C["시각·접근성 회귀 검사"]
    C --> D["이전 토큰 deprecated"]
    D --> E["사용처 0 확인"]
    E --> F["이전 토큰 제거"]

실무 체크리스트

토큰 구조

컴포넌트

운영

마무리

Tailwind는 디자인 시스템을 대신하지 않는다. 대신 잘 정의된 토큰을 빠르게 소비할 수 있는 utility API를 제공한다. 토큰이 없다면 빠른 작성 속도만큼 임의값과 상태 조합도 빠르게 퍼진다.

원시 palette와 spacing은 디자인 재료를 정의한다. 의미 토큰은 surface, action, danger처럼 제품의 역할을 정의한다. 컴포넌트 variant는 이 토큰을 일관된 상태 조합으로 묶는다. Tailwind v4의 @theme은 토큰을 utility로 연결하고, 일반 CSS custom property는 runtime theme 값과 내부 계산을 맡을 수 있다.

좋은 토큰 체계는 모든 숫자에 이름을 붙이는 것이 아니라, 함께 바뀌어야 하는 디자인 결정을 같은 이름 아래 모으는 것이다. 임의값을 없애는 데 집착하기보다 반복되는 예외가 제품 규칙으로 승격될 시점을 관리해야 한다.

관련 노트

참고 자료