색을 손 가는 대로 #hex로 박아 넣다 보면 같은 회색인데 미묘하게 다른 회색이 열 종류씩 생깁니다. 색상 시스템은 쓸 수 있는 색을 미리 정해 두고 그 이름만 부르게 만드는 장치입니다. 아래 코드는 원시 색을 스케일로 깔고, 그 위에 의미를 입힌 토큰을 얹어 컴포넌트가 색 값을 몰라도 되게 만드는 흐름입니다.

36.1 원시 색을 스케일로 깔기

먼저 브랜드가 쓸 원시 색을 밝기 단계로 죽 늘어놓습니다. 50이 가장 밝고 900이 가장 어둡게, 숫자만 봐도 명도가 읽히도록 이름을 붙입니다.


:root {
--gray-50: #f8fafc;
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
--gray-500: #64748b;
--gray-700: #334155;
--gray-900: #0f172a;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--red-500: #ef4444;
--green-500: #22c55e;
}


이 단계의 색들은 아직 무슨 일을 하는지 모릅니다. 물감통에 짜 놓은 물감일 뿐이라, 컴포넌트에서 직접 쓰지는 않습니다.

36.2 의미를 입힌 시맨틱 토큰

원시 색 위에 이 색은 무슨 용도라는 의미를 한 겹 씌웁니다. 배경, 글자, 강조처럼 역할로 이름을 짓고 값은 원시 토큰을 참조하게 합니다.


:root {
--color-bg: var(--gray-50);
--color-surface: #ffffff;
--color-text: var(--gray-900);
--color-muted: var(--gray-500);
--color-border: var(--gray-200);
--color-primary: var(--blue-600);
--color-primary-hover: var(--blue-700);
}


이렇게 두면 나중에 브랜드 색이 파랑에서 남색으로 바뀌어도 --color-primary 한 줄만 다른 원시 색을 가리키게 고치면 끝입니다. 색을 부르는 쪽은 아무것도 건드리지 않아도 됩니다.

36.3 컴포넌트는 시맨틱 토큰만 본다

실제 버튼과 카드 스타일에서는 #hex나 원시 토큰을 쓰지 않고 오직 시맨틱 토큰만 참조합니다. 이 규칙 하나가 색상 시스템의 핵심입니다.


.card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 20px;
}
.card .sub {
color: var(--color-muted);
}
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn-primary:hover {
background: var(--color-primary-hover);
}


이 CSS 어디에도 파랑이나 회색이라는 실제 값이 없습니다. 색을 갈아치우는 작업이 토큰 정의부 몇 줄로 좁혀지는 이유입니다.

36.4 상태를 알리는 색

위험, 성공, 경고처럼 상황을 알리는 색도 역할 토큰으로 잡아 둡니다. 배경이 옅고 글자가 진한 짝을 함께 정의하면 알림 상자를 만들기가 편합니다.


:root {
--color-danger: var(--red-500);
--color-danger-bg: #fef2f2;
--color-success: var(--green-500);
--color-success-bg: #f0fdf4;
}
.alert {
padding: 12px 16px;
border-radius: 6px;
border-left: 4px solid transparent;
}
.alert-danger {
background: var(--color-danger-bg);
border-left-color: var(--color-danger);
color: var(--color-danger);
}
.alert-success {
background: var(--color-success-bg);
border-left-color: var(--color-success);
color: var(--color-success);
}


alert 클래스에 alert-danger만 얹으면 빨간 계열 한 벌이 통째로 걸립니다. 색을 외울 필요 없이 위험이라는 뜻만 붙이면 됩니다.

36.5 색 위에 얹는 글자색

파란 버튼 위 글자는 하양, 노란 배지 위 글자는 검정이어야 읽힙니다. 배경마다 그 위에 올릴 글자색을 짝지어 온컬러 토큰으로 정해 두면 대비가 깨지지 않습니다.


:root {
--on-primary: #ffffff;
--on-warning: var(--gray-900);
--color-warning: #facc15;
}
.btn-primary {
background: var(--color-primary);
color: var(--on-primary);
}
.badge-warning {
background: var(--color-warning);
color: var(--on-warning);
}


밝은 노랑 위에 하얀 글자를 얹으면 눈을 찡그려야 겨우 읽힙니다. 온컬러 토큰은 배경과 글자를 한 쌍으로 묶어 그런 사고를 미리 막아 줍니다.

36.6 다크모드는 토큰만 갈아 끼운다

다크모드를 만든다고 컴포넌트 CSS를 두 벌 쓰지 않습니다. data-theme 속성이 dark일 때 시맨틱 토큰의 값만 어두운 원시 색으로 바꿔치기하면 됩니다.


[data-theme="dark"] {
--color-bg: var(--gray-900);
--color-surface: #1e293b;
--color-text: var(--gray-50);
--color-muted: var(--gray-300);
--color-border: var(--gray-700);
--color-primary: var(--blue-500);
--color-primary-hover: #60a5fa;
}


36.3의 .card와 .btn-primary는 한 글자도 안 고쳤는데 문서 루트에 data-theme="dark"만 붙으면 통째로 어두운 옷으로 갈아입습니다. 조명 스위치 하나로 방 전체 색이 바뀌는 셈입니다.

36.7 시스템 설정을 따라가기

사용자가 운영체제를 다크로 쓰고 있으면 첫 화면부터 어둡게 맞춰 주는 편이 친절합니다. prefers-color-scheme 미디어쿼리로 같은 토큰 오버라이드를 자동 적용합니다.


@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--gray-900);
--color-surface: #1e293b;
--color-text: var(--gray-50);
--color-border: var(--gray-700);
--color-primary: var(--blue-500);
}
}


이러면 별도 버튼을 누르지 않아도 시스템이 어두우면 사이트도 어둡게 나옵니다. data-theme 토글과 함께 쓰면 자동 감지에 수동 전환까지 갖춘 셈이 됩니다.

36.8 정리

색상 시스템은 원시 색 스케일을 깔고, 그 위에 역할 토큰을 씌우고, 컴포넌트는 역할 토큰만 부르게 하는 세 층 구조입니다. 이 구조를 지키면 브랜드 색 교체도, 다크모드도, 상태색 통일도 전부 토큰 정의부 몇 줄을 고치는 일로 줄어듭니다. 색을 값이 아니라 이름으로 다루기 시작하는 순간 화면 전체의 색이 한 손에 잡힙니다.