🧩 Design System2. Color System (OKLCH·다크모드)05 — color-mix() and Dynamic Themes

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%)명확히 눌림
disabledmix(c, gray 50%)흐림
disabled on lightmix(c, white 60%)페이드아웃

Tip: hover보다 active가 더 진해야 한다 (active는 눌림 직후 짧은 순간). 약 2배 정도.

Alpha 비율 가이드

용도비율비고
hover overlay (subtle)4~8%barely there
selected row10~15%명확히 강조
focus ring tint20~30%가시성
toast background tint8~12%brand 힌트
disabled overlay50%+명확한 흐림

빌드 타임 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%는 activelong 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-mixcurrentColor와 안 섞기

/* 부모 텍스트 색을 자동 추적하는 hover overlay */
.link:hover::before {
  background: color-mix(in oklch, currentColor 10%, transparent);
}

currentColorcolor-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 양쪽에 동시에 박는 법.