05 — color-mix() and Dynamic Themes
이 문서가 답하는 질문: hover/active/focus state의 색을 매번 디자이너가 손으로 골라야 하나? 1개의 brand 색에서 공식만으로 전체 ramp를 만들 수는 없을까? 한 줄 답 (Pyramid Top):
color-mix(in oklch, ...)는 “디자인 시스템을 정적 토큰 테이블에서 동적 함수로 끌어올린다”. brand 색 1개와 white·black의 비율 혼합으로 step 1~12 ramp를, 같은 색과 transparent의 비율로 alpha ramp를, 한 줄로 만든다. 디자이너가 hover 색을 고르지 않는 시대다.
Why — 왜 동적 파생이 필요한가
정적 토큰의 한계
:root {
--blue-9: oklch(0.55 0.23 247);
--blue-10: oklch(0.51 0.22 247); /* hover — 손으로 골라야 함 */
}12-step × 10개 hue × 2개 theme = 240개 값을 디자이너가 일일이 골라야 한다. 추가로:
| 추가 차원 | 곱 |
|---|---|
| brand 변종 (3개) | × 3 |
| state (focus, disabled) | × 2 |
| alpha (12-step) | × 12 |
| → 총 | ~17,000개 |
이걸 사람이 손으로 관리하는 건 기억의 한계를 넘는다. 결과: 일관성 손실.
color-mix()의 등장 (CSS Color 5, 2023)
color-mix(in <colorspace>, <color1> [<pct>], <color2> [<pct>])- 두 색을 지정한 색공간에서 비율로 섞음
in oklch이면 perceptually uniform 보간- 한 줄로 step 1~12를 공식으로 파생 가능
브라우저 지원: Chrome 111+, Safari 16.2+, Firefox 113+ (모두 2023년). 2024년 안전. → caniuse.com 95%+ (2026 기준).
How — 5가지 패턴
1) State variation — hover/active/focus
:root {
--color-brand: oklch(0.55 0.23 247);
}
.btn-primary {
background: var(--color-brand);
}
.btn-primary:hover {
/* brand에 검정 10% — 살짝 어둡게 */
background: color-mix(in oklch, var(--color-brand), black 10%);
}
.btn-primary:active {
/* brand에 검정 20% — 더 어둡게 */
background: color-mix(in oklch, var(--color-brand), black 20%);
}
.btn-primary:disabled {
/* brand에 회색 50% — 흐림 */
background: color-mix(in oklch, var(--color-brand), gray 50%);
}step 10이 따로 필요 없다. 공식 1개가 모든 brand의 hover를 만든다.
2) Alpha overlay — tint/shade 동적 생성
:root {
--color-brand: oklch(0.55 0.23 247);
}
.row:hover {
/* brand에 80% 투명도 — 거의 안 보이는 tint */
background: color-mix(in oklch, var(--color-brand) 8%, transparent);
}
.row[aria-selected="true"] {
background: color-mix(in oklch, var(--color-brand) 15%, transparent);
}
.badge {
/* tint chip */
background: color-mix(in oklch, var(--color-brand) 10%, white);
color: color-mix(in oklch, var(--color-brand), black 20%);
}이게 Radix의 alpha 12-step을 코드로 푸는 법이다.
3) 12-step ramp 한 줄로 파생
:root {
--c: oklch(0.55 0.23 247); /* brand anchor */
--c-1: color-mix(in oklch, var(--c) 2%, white);
--c-2: color-mix(in oklch, var(--c) 5%, white);
--c-3: color-mix(in oklch, var(--c) 12%, white);
--c-4: color-mix(in oklch, var(--c) 20%, white);
--c-5: color-mix(in oklch, var(--c) 30%, white);
--c-6: color-mix(in oklch, var(--c) 45%, white);
--c-7: color-mix(in oklch, var(--c) 60%, white);
--c-8: color-mix(in oklch, var(--c) 80%, white);
--c-9: var(--c);
--c-10: color-mix(in oklch, var(--c) 90%, black);
--c-11: color-mix(in oklch, var(--c), black 30%);
--c-12: color-mix(in oklch, var(--c), black 60%);
}⚠️ 정확한 비율은 Radix가 손수 튜닝한 결과만큼 깨끗하지 않을 수 있다. 그래도 *brand의 90%*는 이 공식으로 합리적인 ramp가 나온다. 정밀도가 필요하면 빌드 타임 스크립트로 미리 계산 (다음 챕터).
4) Multi-brand 자동 전환
:root { --c: oklch(0.55 0.23 247); } /* Brand A — blue */
[data-brand="b"]{ --c: oklch(0.58 0.19 140); } /* Brand B — green */
[data-brand="c"]{ --c: oklch(0.62 0.20 350); } /* Brand C — pink */Ramp 정의는 그대로. brand anchor 1개만 바뀌면 전체 ramp가 자동 재계산. 6개 brand × 12 step = 72개 토큰 → 6개 anchor.
5) Theme + Brand 직교 (multi-dimensional)
:root {
--c: oklch(0.55 0.23 247);
--c-3: color-mix(in oklch, var(--c) 12%, var(--bg-base));
--c-9: var(--c);
--c-11: color-mix(in oklch, var(--c), var(--fg-base) 30%);
/* light: bg=white, fg=black */
--bg-base: white;
--fg-base: black;
}
[data-theme="dark"] {
--bg-base: oklch(0.14 0 0); /* dark page bg */
--fg-base: oklch(0.94 0 0); /* dark text */
/* --c는 brand이므로 약간 밝게 */
--c: oklch(0.62 0.20 247);
}Light에선 c-3가 거의 흰색에 brand tint, dark에선 c-3가 짙은 회색에 brand tint. 같은 공식 → 다른 결과 — 이게 동적 토큰의 진짜 가치.
What — 구체 코드 + 사양
color-mix() 문법 (CSS Color 5)
color-mix(in <method> [<hue-method>], <color> [<percentage>], <color> [<percentage>])| 인자 | 값 |
|---|---|
<method> | srgb·hsl·hwb·lab·lch·oklab·oklch 등 |
<hue-method> | shorter hue·longer hue·increasing hue·decreasing hue (LCH/OKLCH만) |
<color> | 모든 CSS 색 표현 |
<percentage> | 0%~100%. 생략 시 자동 보정 |
/* 균등 50-50 */
color-mix(in oklch, red, blue);
/* = color-mix(in oklch, red 50%, blue 50%) */
/* 30 / 70 */
color-mix(in oklch, red 30%, blue);
/* = color-mix(in oklch, red 30%, blue 70%) — auto */
/* 색상환 긴 길로 보간 */
color-mix(in oklch longer hue, red, blue); /* 노→초→청 거쳐서 */Hover 깊이 공식 — 어떤 비율이 적당한가
| 깊이 | 공식 | 시각 결과 |
|---|---|---|
| hover 가벼움 | mix(c, black 6%) | 거의 모르겠다 |
| hover 적당 | mix(c, black 10%) | 적절 (대부분의 디자인) |
| active 무거움 | mix(c, black 20%) | 명확히 눌림 |
| disabled | mix(c, gray 50%) | 흐림 |
| disabled on light | mix(c, white 60%) | 페이드아웃 |
Tip: hover보다 active가 더 진해야 한다 (active는 눌림 직후 짧은 순간). 약 2배 정도.
Alpha 비율 가이드
| 용도 | 비율 | 비고 |
|---|---|---|
| hover overlay (subtle) | 4~8% | barely there |
| selected row | 10~15% | 명확히 강조 |
| focus ring tint | 20~30% | 가시성 |
| toast background tint | 8~12% | brand 힌트 |
| disabled overlay | 50%+ | 명확한 흐림 |
빌드 타임 vs 런타임 — 어느 쪽?
color-mix()는 런타임에 브라우저가 계산한다. 빌드 타임에 미리 계산해 hex로 박는 방법도 있다.
| 시점 | 장점 | 단점 |
|---|---|---|
런타임 color-mix() | brand anchor 바꾸면 전체 자동 갱신, CSS 크기 작음 | 브라우저 계산 비용 (사실상 무시) |
| 빌드 타임 (Style Dictionary, etc.) | 정밀 튜닝 가능, 구형 브라우저 호환 | 토큰 수 폭발, 빌드 단계 추가 |
권장: 기본은 런타임. 정밀도가 필요한 *brand 핵심 step (9~12)*만 빌드 타임에서 손수 튜닝.
Tailwind v4의 --alpha() 함수
Tailwind v4는 자기 디자인 토큰을 oklch에 박은 다음, opacity utility (bg-blue-500/30)를 color-mix로 컴파일한다.
/* Tailwind v4 컴파일 결과 (대략) */
.bg-blue-500\/30 {
background-color: color-mix(in oklab, var(--color-blue-500) 30%, transparent);
}즉 Tailwind를 쓰는 것 자체가 color-mix를 간접적으로 쓰는 것이다.
What — 실제 컴포넌트 예시
완성된 Button (Solid + Soft variant)
:root {
--color-brand: oklch(0.55 0.23 247);
}
/* Solid */
.btn-solid {
background: var(--color-brand);
color: white;
border: none;
}
.btn-solid:hover { background: color-mix(in oklch, var(--color-brand), black 10%); }
.btn-solid:active { background: color-mix(in oklch, var(--color-brand), black 20%); }
.btn-solid:focus-visible {
outline: 2px solid color-mix(in oklch, var(--color-brand) 50%, transparent);
outline-offset: 2px;
}
.btn-solid:disabled {
background: color-mix(in oklch, var(--color-brand), gray 60%);
cursor: not-allowed;
}
/* Soft */
.btn-soft {
background: color-mix(in oklch, var(--color-brand) 12%, white);
color: color-mix(in oklch, var(--color-brand), black 20%);
border: 1px solid color-mix(in oklch, var(--color-brand) 30%, white);
}
.btn-soft:hover { background: color-mix(in oklch, var(--color-brand) 18%, white); }
.btn-soft:active { background: color-mix(in oklch, var(--color-brand) 25%, white); }
/* Outline */
.btn-outline {
background: transparent;
color: var(--color-brand);
border: 1px solid color-mix(in oklch, var(--color-brand) 50%, white);
}
.btn-outline:hover { background: color-mix(in oklch, var(--color-brand) 8%, transparent); }디자이너가 정의한 건 --color-brand 1개. 모든 variant·state가 공식으로 파생. brand 색이 바뀌면 모든 게 자동 갱신.
Toast (status별 색)
.toast {
--status: var(--color-brand);
background: color-mix(in oklch, var(--status) 10%, white);
border-left: 4px solid var(--status);
color: color-mix(in oklch, var(--status), black 30%);
}
.toast[data-status="success"] { --status: oklch(0.65 0.18 145); }
.toast[data-status="warning"] { --status: oklch(0.78 0.15 70); }
.toast[data-status="error"] { --status: oklch(0.60 0.22 27); }
.toast[data-status="info"] { --status: oklch(0.62 0.18 240); }4가지 상태가 전부 같은 공식. 상태 추가는 anchor 1줄만 추가하면 끝.
What-if — 잘못 다루면
1) in srgb 또는 색공간 생략
/* 안 됨 — 진흙색 중간 */
color-mix(red, blue); /* 기본은 srgb */
color-mix(in srgb, red, blue);/* 옳음 */
color-mix(in oklch, red, blue); /* 깨끗한 보라 */색공간 반드시 명시. in oklch 또는 in oklab.
2) Hue shift 무시
/* 색상환의 짧은 길로 보간 (기본) */
color-mix(in oklch, red, blue);
/* 명시적으로 다른 방향 — 의도가 다른 결과 */
color-mix(in oklch longer hue, red, blue);State variation에선 *shorter (기본)*이 항상 맞다. 단, gradient에선 longer가 필요한 경우도 있다.
3) Disabled를 gray로 mix
/* gray가 sRGB 기준이라 OKLCH 보간에서 흙색 됨 */
background: color-mix(in oklch, var(--brand), gray 50%);/* OKLCH 회색 명시 */
background: color-mix(in oklch, var(--brand), oklch(0.6 0 0) 50%);또는 brand의 L만 떨어뜨려서 채도 죽이기:
background: oklch(from var(--brand) calc(l - 0.05) 0.05 h);4) Hover 색이 너무 두꺼움
.btn:hover { background: color-mix(in oklch, var(--brand), black 30%); } /* 너무 진함 */10% 정도가 대부분의 디자인에서 적당. 30%는 active나 long hover (sticky). 시각적으로 원래 색의 family에 있다는 느낌이 유지돼야 한다.
5) Alpha를 hex로 박기
/* ✗ */
background: rgba(51, 112, 184, 0.1); /* hex로 박힌 brand */brand 색 바뀌면 전부 손으로 수정. color-mix + transparent가 답:
background: color-mix(in oklch, var(--color-brand) 10%, transparent);6) color-mix를 currentColor와 안 섞기
/* 부모 텍스트 색을 자동 추적하는 hover overlay */
.link:hover::before {
background: color-mix(in oklch, currentColor 10%, transparent);
}currentColor와 color-mix의 조합이 진짜 강력하다. 부모 색이 어떤 값이든 그 색의 10% 투명 overlay가 자동 생성.
Insight — Open Props가 color-mix로 푼 “팔레트 인플레이션”
“디자인 토큰의 인플레이션을 1줄로 잡았다”
2022년까지 디자인 시스템의 가장 큰 골칫거리는 팔레트 인플레이션이었다. “primary-50 ~ primary-900”만 두면 디자이너가 그 사이 색을 자꾸 요구한다. “여기엔 100과 200 사이가 필요해”. 결과적으로 수십 ~ 수백 개의 색 토큰이 쌓인다.
Adam Argyle의 Open Props는 2022년 12월에 Color-mix 기반 ramp를 1.7 버전에서 발표하며 이 문제를 문법적으로 해결했다.
/* Open Props 패턴 */
.theme-blue {
--hue: 220;
}
.bg { background: oklch(96% var(--chroma, 0.03) var(--hue)); }
.btn-bg { background: oklch(55% 0.22 var(--hue)); }
.btn-bg-hover { background: oklch(48% 0.22 var(--hue)); }또는 anchor + color-mix:
.btn { background: var(--brand); }
.btn:hover { background: color-mix(in oklch, var(--brand), black 10%); }toolchain 없이 CSS만으로 모든 ramp가 나온다. Style Dictionary 같은 빌드 도구를 안 쓰는 작은 팀의 민주화 도구가 됐다.
흥미로운 반전 — color-mix가 너무 강력해서 디자이너의 역할 일부를 흡수하는 현상도 생긴다. 예전엔 디자이너가 step 4 색을 골라줬는데, 이제는 공식이 step 4를 만든다. 디자이너의 일은 anchor 한 점과 공식의 비율 튜닝으로 옮겨갔다. 디자인 시스템 디자이너의 프로그래머화가 일어나는 지점이다.
또 하나 — Tailwind v4가 내부적으로 color-mix를 사용하면서 디자이너의 알파 노테이션 (bg-blue-500/30)이 자동으로 OKLCH 보간이 됐다. 사용자는 무엇이 바뀌었는지조차 모른다. 원리는 바뀌었는데 API는 그대로 — 표준의 진화가 조용히 사용자를 더 좋은 색공간으로 옮긴 사례.
요약
color-mix(in oklch, ...)는 디자인 토큰을 정적 테이블에서 동적 함수로 끌어올린다.- 5가지 패턴: state variation / alpha overlay / 12-step ramp / multi-brand / theme + brand.
- 공식:
mix(c, black 10%)= hover,mix(c, black 20%)= active,mix(c X%, transparent)= tint. - Tailwind v4가 내부적으로 사용 — 사용자는 모르게 OKLCH 보간으로 업그레이드됨.
- anchor 1개로 ramp 12개 — 디자이너의 역할이 색 고르기에서 anchor + 공식으로 이동.
다음: 06-color-token-mapping-panda-tailwind — 이 모든 걸 Panda × Tailwind 양쪽에 동시에 박는 법.