사이드 프로젝트에 디자인 시스템 비슷한 걸 만들면서 제일 오래 붙잡은 게 컬러 스케일이었어요. Tailwind 같은 라이브러리를 보면 blue-50 부터 blue-950 까지 깔끔하게 정리돼 있는데, 막상 우리 브랜드 색 하나로 그걸 만들려니 어디서부터 해야 할지 몰랐어요.
몇 번 갈아엎은 끝에 정착한 결론은 이래요. 기준 색의 위치를 먼저 정하고, OKLCH 밝기로 양 끝과 단계를 배치한 뒤, 채도와 색상을 살짝 보정하고, 마지막으로 단계마다 명도 대비를 계산해 용도를 정한다. 이 글에서는 그 순서를 실제로 돌아가는 생성 코드와 함께 보여 드리고, 흔히 쓰는 "HSL 밝기 10%씩 나누기"와 결과가 어떻게 다른지 직접 비교해 봤어요.
핵심 요약
브랜드 색 하나로는 화면을 못 만들어서 배경·hover·테두리·텍스트용으로 밝기만 다른 10~11단계가 필요해요.
HSL 밝기를 10%씩 균등하게 나누면 어두운 쪽 800·900·950이 흰 배경 대비 15:1을 넘는 거의 검정으로 뭉쳐요.
OKLCH 밝기로 단계를 정하고 끝단 채도를 낮추면 쓸모 있는 단계가 고르게 퍼져요.
단계별 명도 대비를 표로 만들면 "흰 글자 버튼은 600부터" 같은 사용 규칙이 자연스럽게 나와요.
왜 컬러 스케일 단계가 필요한가
브랜드 색 하나로는 화면을 못 만들어요. 버튼 배경, hover, 눌림, 연한 배경, 테두리, 본문 링크, 다크 모드 버전까지 한 색상에서 밝기만 다른 변형이 열 개 가까이 필요해요. 이걸 그때그때 만들면 화면마다 미묘하게 다른 파란색이 열다섯 개쯤 생겨요. 단계를 미리 정해 두면 "여기는 blue-100, 저기는 blue-700"처럼 이름으로 대화할 수 있어요.
숫자 체계는 50, 100, 200 … 900, 950이 흔해요. 숫자가 클수록 어두워요. 50과 950은 나중에 끝단이 더 필요해서 추가된 단계라고 보면 돼요. 숫자 사이를 비워 둔 덕에 나중에 450 같은 중간 단계를 끼워 넣을 수도 있어요.
만드는 순서 6단계
- 기준 색이 몇 번에 들어갈지 정한다. 브랜드 색이 중간 밝기라면 500이나 600에 둬요. 아주 밝은 브랜드 색(연두, 노랑)이라면 300~400이 자연스러워요. 기준 색을 억지로 500에 맞추면 나머지 단계가 한쪽으로 몰려요.
- 양 끝을 먼저 잡는다. 50은 배경으로 쓸 만큼 거의 흰색에 가까운 톤, 950은 거의 검정에 가까운 짙은 톤. 끝을 먼저 정하고 사이를 채우는 게 반대보다 쉬워요.
- 밝기를 지각적으로 나눈다. HSL의 L을 10%씩 똑같이 나누지 말고, 사람 눈의 밝기에 가까운 OKLCH의 L로 단계를 정해요.
- 채도와 색상을 살짝 틀어 준다. 밝기만 바꾸면 밝은 단계는 형광처럼, 어두운 단계는 칙칙하게 나와요. 양 끝으로 갈수록 채도를 줄이고, 색상(H)을 몇 도씩 돌리면 생기가 살아요.
- 대비 검사로 용도를 정한다. 각 단계의 흰 배경·검정 배경 대비를 표로 만들어요.
- 다크 모드는 따로 재검사한다. 50과 950의 역할을 맞바꾸면 대체로 맞지만, 중간 단계는 다시 확인해야 해요.
OKLCH로 스케일 생성하는 코드
3·4단계를 코드로 옮기면 이래요. sRGB와 OKLab 변환식(Björn Ottosson이 공개한 행렬)만 있으면 외부 라이브러리 없이 돌아가요.
const toLin = c => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const toGam = c => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
function hexToOklch(hex) {
const [r, g, b] = hex.slice(1).match(/../g).map(h => toLin(parseInt(h, 16) / 255));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return { L, C: Math.hypot(A, B), H: (Math.atan2(B, A) * 180 / Math.PI + 360) % 360 };
}
function oklchToRgb({ L, C, H }) {
const a = C * Math.cos(H * Math.PI / 180), b = C * Math.sin(H * Math.PI / 180);
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s].map(toGam);
}
function oklchToHex(c) {
let { C } = c, rgb;
for (;;) { // sRGB 밖이면 채도를 줄여 안으로 끌어온다
rgb = oklchToRgb({ ...c, C });
if (rgb.every(v => v >= -1e-4 && v <= 1 + 1e-4) || C <= 0) break;
C -= 0.002;
}
return '#' + rgb.map(v => Math.round(Math.min(1, Math.max(0, v)) * 255)
.toString(16).padStart(2, '0')).join('');
}
const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const LIGHT = [0.97, 0.93, 0.87, 0.79, 0.70, 0.62, 0.54, 0.47, 0.39, 0.32, 0.25];
const CHROMA = [0.15, 0.3, 0.5, 0.75, 0.9, 1, 1, 0.95, 0.85, 0.7, 0.55];
function makeScale(base) {
const { C, H } = hexToOklch(base);
return STEPS.map((step, i) =>
[step, oklchToHex({ L: LIGHT[i], C: C * CHROMA[i], H: H + (i - 5) * 1.5 })]);
}
makeScale('#2A78D6');
// [[50,'#eef6ff'], [100,'#d7eaff'], [200,'#b5d8ff'], [300,'#87bffe'], [400,'#59a1f7'],
// [500,'#3986e5'], [600,'#246ccb'], [700,'#1957af'], [800,'#10408d'], [900,'#0e2e6c'], [950,'#091e4c']]
LIGHT 배열이 단계별 지각 밝기, CHROMA 배열이 기준 채도 대비 비율이에요. 양 끝으로 갈수록 채도를 줄여서 50이 형광 하늘색이 되지 않게, 950이 탁한 남색이 되지 않게 해요. 색상은 단계마다 1.5도씩 돌렸어요. 파랑은 어두운 쪽에서 살짝 보라 쪽으로 가면 깊이감이 생겨요. 이 숫자들은 정답이 아니라 출발점이에요. 눈으로 보면서 조정하세요.
HSL 10% 균등 분할과 비교해 보니
흔히 쓰는 간단한 방법은 기준 색의 H와 S를 고정하고 HSL 밝기를 95, 85, 75 … 5%로 10%씩 나누는 거예요. 같은 기준 색으로 두 방식의 단계별 흰 배경 대비를 계산해 봤어요.
HSL 균등 분할은 800 이후 대비가 15:1을 넘어 사실상 검정 세 단계가 된다
표로 보기
| 구분 | OKLCH 생성 | HSL 균등 |
|---|---|---|
| 50 | 1.1 | 1.1 |
| 100 | 1.2 | 1.5 |
| 200 | 1.5 | 2 |
| 300 | 1.9 | 2.7 |
| 400 | 2.7 | 3.8 |
| 500 | 3.7 | 5.3 |
| 600 | 5.1 | 7.7 |
| 700 | 6.9 | 11.2 |
| 800 | 9.8 | 15.6 |
| 900 | 12.9 | 17.9 |
| 950 | 16.1 | 19.6 |
HSL 균등 분할은 700에서 이미 11:1을 넘고, 800·900·950은 15.6~19.7:1로 거의 검정이에요. 세 단계를 만들었는데 눈으로는 구분하기 어려운 "검정 셋"이 된 거죠. 반대로 흰 글자 4.5:1을 넘는 첫 단계가 500이라서, 디자이너가 기대하는 "500 = 브랜드 기본"과 어긋나요.
인접 단계 사이의 지각적 색 차이(OKLab 거리)도 계산해 봤어요. 값이 고를수록 단계가 일정한 간격으로 느껴져요.
OKLCH 쪽의 50-100 간격이 좁은 건 의도예요. 밝은 단계는 넓은 면적의 배경에 쓰이는데, 면적이 넓을수록 작은 차이도 크게 보이거든요. HSL 균등 쪽은 중간까지는 고르다가 800 이후 간격이 급격히 줄어요. 숫자로 보면 "HSL은 틀렸다"기보다 "끝단을 의도대로 통제하기 어렵다" 가 정확한 평가예요.
단계별 대비로 용도 정하기
생성한 스케일의 대비를 표로 만들면 사용 규칙이 자연스럽게 나와요.
| 단계 | 흰 배경 대비 | 검정 배경 대비 | 주 용도 |
|---|---|---|---|
| 50 | 1.09 | 19.26 | 섹션 배경, 선택된 행 배경 |
| 100–200 | 1.22–1.47 | 14.23–17.1 | 배지 배경, 연한 테두리 |
| 300–400 | 1.92–2.66 | 7.86–10.91 | 비활성 요소, 다크 모드 텍스트 링크 |
| 500 | 3.67 | 5.71 | 큰 글자 강조, 아이콘(3:1 이상) |
| 600 | 5.14 | 4.08 | 기본 버튼(흰 글자), 흰 배경 링크 |
| 700 | 6.94 | 3.02 | 버튼 hover, 눌림 |
| 800–950 | 9.81–16.13 | 1.3–2.14 | 진한 제목, 다크 모드 배경 |
여기서 하나 배웠어요. 기준 색 #2A78D6 자체는 흰 글자와 4.41:1로 본문 기준(4.5:1)에 살짝 못 미쳐요. 그래서 버튼 기본 색은 600을 쓰고, 기준 색은 큰 글자나 아이콘에만 써요. 대비 기준 자체는 WCAG 명도 대비 가이드에 공식과 함께 정리해 뒀어요.
토큰 이름은 두 층으로
--blue-600 같은 원시 토큰 위에 --color-primary, --color-primary-hover 같은 의미 토큰을 한 층 더 둬요. 컴포넌트에서는 의미 토큰만 쓰게 하면, 나중에 브랜드 색을 바꾸거나 다크 모드를 붙일 때 매핑만 바꾸면 돼요.
:root {
--blue-50: #eef6ff; --blue-300: #87bffe; --blue-400: #59a1f7;
--blue-600: #246ccb; --blue-700: #1957af; --blue-950: #091e4c;
--color-primary: var(--blue-600);
--color-primary-hover: var(--blue-700);
--color-surface-accent: var(--blue-50);
}
@media (prefers-color-scheme: dark) {
:root {
--color-primary: var(--blue-400);
--color-primary-hover: var(--blue-300);
--color-surface-accent: var(--blue-950);
}
}
다크 모드에서 의미 토큰이 가리키는 단계만 바꾼 게 보이죠. 다크 배경에서는 밝은 쪽 단계가 텍스트와 버튼 역할을 해요. 이때 버튼 위 글자색도 흰색 대신 어두운 색으로 바꿔야 대비가 맞는 경우가 많으니 다시 계산하세요.
회색과 상태 색 스케일도 같은 규칙으로
브랜드 색만 만들고 끝내면 화면의 절반을 차지하는 회색이 따로 놀아요. 회색 스케일도 같은 LIGHT 배열로 만들되, 채도를 0으로 두지 말고 브랜드 색상 쪽으로 아주 약간(OKLCH C 0.005~0.015 정도) 틀어 주면 화면 전체가 한 팔레트처럼 보여요. 완전한 무채색 회색은 파란 브랜드 옆에서 살짝 누렇게 느껴지는 경우가 많거든요.
성공·경고·오류 같은 상태 색도 같은 생성 코드에 넣어 봤어요. 여기서 OKLCH의 장점이 확실히 드러나요.
| 상태 색 (기준 색) | 500 흰 글자 대비 | 600 흰 글자 대비 | 600의 인상 |
|---|---|---|---|
오류 (빨강 #DC2626) | 4.04 | 5.67 | 선명한 빨강 |
성공 (초록 #16A34A) | 3.38 | 4.74 | 짙은 초록 |
경고 (노랑 #EAB308) | 3.68 | 5.07 | 겨자·황토색 |
경고 (주황 #F97316) | 3.89 | 5.36 | 적갈색 주황 |
정보 (파랑 #2A78D6) | 3.67 | 5.14 | 브랜드 파랑 |
같은 LIGHT 규칙이라 모든 색상이 600부터 흰 글자 4.5:1을 넘어요. HSL로 만들었다면 색상마다 경계 단계가 제각각이었을 거예요. 이게 "같은 단계 번호 = 같은 용도" 규칙을 색상과 무관하게 쓸 수 있게 해 줘요.
다만 노랑은 600이 되면 더 이상 노랑으로 안 보이고 겨자색이 돼요. 그래서 경고 배너에 흰 글자를 올리기보다는, 연한 노랑 배경(100)에 어두운 글자(900) 조합이 가장 무난해요. 숫자가 통과해도 "그 색이 여전히 그 의미로 읽히는가"는 눈으로 확인해야 해요.
색만으로 상태를 구분하지 않는 것도 중요해요. 빨강과 초록은 적록 색각 이상이 있는 사용자에게 비슷하게 보일 수 있어서, 아이콘(체크, 느낌표)이나 문구를 함께 써야 해요.
자주 하는 실수
- 기준 색을 무조건 500에 둔다 — 밝은 브랜드 색이면 나머지가 한쪽으로 쏠려요.
- 단계를 다 만들고 대비를 안 잰다 — 어느 단계부터 흰 글자를 쓸 수 있는지 모르면 화면마다 다른 단계를 쓰게 돼요.
- HEX를 HSL 정수로 저장한다 — 반올림 오차로 원래 색이 미묘하게 바뀌어요. 원본은 HEX로 보관하세요. 이유는 HEX·RGB·HSL 차이에서 계산으로 보여 드렸어요.
- 한 화면에 단계를 너무 많이 쓴다 — 완성한 뒤 깨달은 건데, 한 화면에서는 서너 단계만 쓰는 게 깔끔해요.
자주 묻는 질문
컬러 스케일은 몇 단계가 적당한가요?
50~950의 11단계가 가장 흔하고, 작은 프로젝트라면 100~900의 9단계로도 충분해요. 단계가 너무 적으면 hover나 테두리 색을 만들 때 중간값이 부족하고, 너무 많으면 화면마다 다른 단계를 써서 일관성이 깨져요.
브랜드 색은 500에 둬야 하나요?
꼭 그럴 필요는 없어요. 중간 밝기라면 500~600, 노랑이나 연두처럼 밝은 색이라면 300~400에 두는 게 자연스러워요. 기준 색 위치는 숫자가 아니라 밝기와 용도로 정하세요.
OKLCH로 만들면 HSL보다 무엇이 좋은가요?
OKLCH의 L은 사람 눈의 밝기에 가까워서, 단계 간격을 지각적으로 고르게 잡기 쉬워요. 직접 비교해 보니 HSL 10% 균등 분할은 어두운 세 단계가 거의 검정으로 뭉쳤어요. 여러 색상의 스케일을 같은 밝기 규칙으로 맞출 때도 OKLCH가 훨씬 일관돼요.
다크 모드용 스케일을 따로 만들어야 하나요?
대부분은 같은 스케일을 쓰고 의미 토큰이 가리키는 단계만 바꾸면 돼요. 다만 다크 배경에서 채도가 높은 색은 눈부시게 보일 수 있어서, 몇몇 단계는 채도를 낮춘 별도 값을 두기도 해요. 어느 쪽이든 대비는 다시 계산해야 해요.
생성기가 만든 색을 그대로 써도 되나요?
출발점으로는 충분하지만 그대로 확정하진 마세요. 노랑·초록처럼 밝기에 민감한 색상은 같은 규칙에서도 어색한 단계가 나와요. 실제 화면에 배치해 보고 채도와 색상을 눈으로 보정하는 과정이 팔레트의 인상을 거의 다 결정해요.
정리
컬러 스케일은 "기준 색 위치 → 양 끝 → 지각적 밝기 배치 → 채도·색상 보정 → 대비로 용도 결정 → 다크 모드 재검사" 순서로 만들면 덜 헤매요. HSL 균등 분할은 간단하지만 어두운 끝이 뭉치기 쉽고, OKLCH는 단계 간격을 의도대로 통제하기 좋아요. 무엇으로 만들든 단계별 대비표가 있어야 팀이 같은 규칙으로 색을 써요.
처음 초안은 색상 코드 변환기에 기준 색을 넣어 밝은 쪽 tint와 어두운 쪽 shade를 뽑아 보고, 위 코드로 OKLCH 버전과 비교해 보세요. 자동으로 나온 단계는 출발점일 뿐이고, 마지막 보정은 결국 사람의 눈이 해요.