Modern CSS Color: OKLCH, color-mix(), and Perceptually Uniform Design Tokens
Why HSL fails for design systems, how OKLCH and color-mix() fix perceptual lightness and palettes, plus P3 gamut, relative colors, and fallbacks.
The Color Problem Nobody Talks About {Vấn đề màu mà ít ai nói tới}
You pick #3b82f6 for primary, #22c55e for success, and #eab308 for warning {Bạn chọn #3b82f6 cho primary, #22c55e cho success, và #eab308 cho warning}. They look fine side by side in Figma {Chúng trông ổn cạnh nhau trong Figma}. Ship them to production {Deploy lên production}, and suddenly the yellow warning badge screams while the blue link feels muted at the same nominal “50% lightness” {và đột nhiên badge warning vàng chói trong khi link xanh trông nhạt ở cùng “50% lightness” danh nghĩa}.
That is not a design failure {Đó không phải lỗi thiết kế}. It is a color space failure {Đó là lỗi không gian màu}.
For twenty years, frontend engineers have lived inside sRGB expressed as hex, rgb(), and hsl() {Suốt hai thập kỷ, frontend engineer sống trong sRGB biểu diễn bằng hex, rgb(), và hsl()}. These formats are convenient {Các định dạng này tiện}, but they encode color in ways that do not match human perception {nhưng chúng mã hóa màu theo cách không khớp cảm nhận thị giác con người}. Modern CSS finally gives us perceptual color spaces — oklch(), color-mix(), relative color syntax, and wide-gamut display-p3 — as first-class citizens {CSS hiện đại cuối cùng đưa không gian màu cảm nhận — oklch(), color-mix(), cú pháp relative color, và wide-gamut display-p3 — thành công dân hạng nhất}.
This post is about choosing and mixing color correctly {Bài viết này về chọn và pha màu đúng cách}, not about selector performance or feature inventories {không phải về performance selector hay liệt kê tính năng}.
Live Demo {Demo trực tiếp}
The best way to internalize perceptual uniformity is to see HSL and OKLCH diverge under the same numbers {Cách tốt nhất để hiểu đồng đều cảm nhận là nhìn HSL và OKLCH lệch nhau với cùng con số}.
Open the full demo {Mở demo đầy đủ}: /tools/css-color-demo/.
Try this in the demo {Thử điều này trong demo}: lock L = 0.75, sweep hue in OKLCH vs HSL at “50% lightness” {khóa L = 0.75, quét hue trong OKLCH vs HSL ở “50% lightness”}. The OKLCH strip stays visually even; the HSL strip does not {Dải OKLCH giữ độ sáng đều; dải HSL thì không}.
Why Hex, RGB, and HSL Fall Short {Tại sao Hex, RGB, và HSL không đủ}
sRGB Is a Delivery Format, Not a Design Space {sRGB là định dạng giao hàng, không phải không gian thiết kế}
Hex and rgb() are encoded sRGB values {Hex và rgb() là giá trị sRGB đã mã hóa} — three 8-bit channels clamped to a small triangle on the CIE chromaticity diagram {ba kênh 8-bit bị giới hạn trong tam giác nhỏ trên biểu đồ sắc độ CIE}. They tell you how to drive pixels {Chúng cho biết cách điều khiển pixel}, not how bright a color looks {không phải màu trông sáng thế nào}.
| Format | What it gives you {Nó cho bạn gì} | What it hides {Nó che giấu gì} |
|---|---|---|
#rrggbb | Compact sRGB triplet {Bộ ba sRGB gọn} | No lightness semantics {Không có ngữ nghĩa độ sáng} |
rgb(r g b) | Same as hex, readable {Giống hex, dễ đọc} | Non-uniform steps feel uneven {Bước không đều cảm giác lệch} |
hsl(h s% l%) | Polar coordinates in sRGB {Tọa độ cực trong sRGB} | L is not perceptual lightness {L không phải độ sáng cảm nhận} |
Key insight {Insight quan trọng}: HSL was designed so you could write
hsl(120 100% 50%)and get “pure green” {HSL được thiết kế để bạn viếthsl(120 100% 50%)và có “xanh lá thuần”}. It was never designed so that50%lightness across hues looks equally bright {Nó không bao giờ được thiết kế để50%lightness qua các hue trông sáng bằng nhau}.
The HSL Lightness Trap {Bẫy lightness của HSL}
/* Same "50% lightness" — wildly different perceived brightness */
.badge-warn { background: hsl(48 96% 53%); } /* screams */
.badge-info { background: hsl(217 91% 60%); } /* calm */
.badge-ok { background: hsl(142 71% 45%); } /* middling */
Design systems that generate scales with hsl(var(--hue) X% Y%) inherit this bias {Design system sinh scale bằng hsl(var(--hue) X% Y%) kế thừa bias này}. Your “neutral” gray ramp might be fine {Ramp xám “neutral” có thể ổn}, but your semantic palette (success, warning, danger) will never feel balanced at the same numeric step {nhưng palette semantic (success, warning, danger) sẽ không cân bằng ở cùng bước số}.
Limited Gamut on Modern Screens {Gamut hạn chế trên màn hình hiện đại}
Most laptops and phones sold since 2020 render Display P3 — roughly 25% more colors than sRGB {Hầu hết laptop và điện thoại từ 2020 render Display P3 — khoảng 25% màu hơn sRGB}. When you only author #rrggbb, you leave that headroom unused {Khi chỉ viết #rrggbb, bạn bỏ phần dư đó}. Vivid brand greens and reds clip or dull when converted {Xanh lá và đỏ brand sống động bị clip hoặc xám khi convert}.
Color Spaces: A Senior Mental Model {Không gian màu: mô hình tư duy senior}
Think in three layers {Nghĩ theo ba lớp}:
- Working space — where you design (OKLCH, OKLAB, LCH) {Working space — nơi bạn thiết kế (OKLCH, OKLAB, LCH)}
- Interpolation space — where gradients and mixes happen {Interpolation space — nơi gradient và pha màu diễn ra}
- Output space — what the screen displays (sRGB, P3) {Output space — màn hình hiển thị gì (sRGB, P3)}
CSS now exposes all three in syntax {CSS giờ expose cả ba trong cú pháp}.
OKLAB and OKLCH {OKLAB và OKLCH}
OKLAB (Oklab) is a perceptually uniform space derived from OKLab color science {OKLAB (Oklab) là không gian đồng đều cảm nhận từ khoa học màu OKLab}. OKLCH is its cylindrical form — the HSL replacement you actually want {OKLCH là dạng trụ — thay thế HSL bạn thực sự cần}:
| Channel | Range (typical) | Meaning {Nghĩa} |
|---|---|---|
| L | 0 – 1 (or 0% – 100%) | Perceived lightness {Độ sáng cảm nhận} |
| C | 0 – ~0.4 | Chroma (saturation intensity) {Chroma (cường độ bão hòa)} |
| H | 0 – 360 | Hue angle {Góc hue} |
:root {
/* Perceptually even steps — each +0.08 L looks like the same jump */
--surface-1: oklch(0.18 0.01 260);
--surface-2: oklch(0.22 0.01 260);
--surface-3: oklch(0.30 0.01 260);
--accent: oklch(0.87 0.23 120); /* lime that pops consistently */
--danger: oklch(0.63 0.24 25); /* red at same perceived weight */
--info: oklch(0.65 0.18 250); /* blue — actually comparable now */
}
Why OKLCH over OKLAB? {Tại sao OKLCH hơn OKLAB?} Same reason HSL beat RGB for hand-authoring: polar coordinates are easier to reason about when rotating hue or holding chroma fixed {Cùng lý do HSL thắng RGB khi viết tay: tọa độ cực dễ suy luận khi xoay hue hoặc giữ chroma cố định}.
Building Accessible Palettes {Xây palette accessible}
WCAG contrast is computed on relative luminance, not HSL L {WCAG contrast tính trên relative luminance, không phải HSL L}. OKLCH L correlates much more closely with luminance than HSL L does {OKLCH L tương quan gần luminance hơn HSL L}. Rule of thumb for dark UI {Quy tắc ngón tay cho dark UI}:
- Text on
oklch(0.15 …)→ aim for text ≥oklch(0.85 …){Text trênoklch(0.15 …)→ nhắm text ≥oklch(0.85 …)} - Hold C low (≤
0.03) for neutrals; raise C for brand accents {Giữ C thấp (≤0.03) cho neutral; tăng C cho accent brand} - Step L in equal increments (
0.05–0.08) for gray ramps {Bước L đều (0.05–0.08) cho gray ramp}
/* Neutral ramp — equal perceived steps */
--gray-1: oklch(0.20 0.01 260);
--gray-2: oklch(0.28 0.01 260);
--gray-3: oklch(0.36 0.01 260);
--gray-4: oklch(0.48 0.01 260);
--gray-5: oklch(0.62 0.01 260);
--gray-6: oklch(0.78 0.01 260);
Always verify with a contrast checker {Luôn verify bằng contrast checker} — OKLCH gets you 90% there without guessing {OKLCH đưa bạn 90% đường đi mà không đoán mò}.
color-mix(): Stop Hand-Picking Midpoints {color-mix(): ngừng chọn midpoint bằng tay}
Before color-mix(), hover states were #1a1a1a pasted from a Figma blend {Trước color-mix(), hover state là #1a1a1a copy từ Figma blend}. Now the browser computes it {Giờ trình duyệt tính}:
.button {
--btn-bg: oklch(0.87 0.23 120);
background: var(--btn-bg);
}
.button:hover {
background: color-mix(in oklch, var(--btn-bg) 85%, black);
}
.button:active {
background: color-mix(in oklch, var(--btn-bg) 70%, black);
}
Which Color Space to Mix In? {Pha trong không gian màu nào?}
The in <color-space> keyword is not cosmetic {Từ khóa in <color-space> không phải trang trí}. It changes the interpolation path {Nó đổi đường nội suy}:
| Mix space | Result {Kết quả} | Use when {Dùng khi} |
|---|---|---|
srgb | Fast, familiar, often muddy midpoints {Nhanh, quen, midpoint thường đục} | Legacy compat, subtle tints {Compat legacy, tint nhẹ} |
hsl | Hue spins unpredictably; lightness uneven {Hue xoay không đoán; lightness không đều} | Rarely — prefer oklch {Hiếm — ưu tiên oklch} |
oklch | Perceptually even blends {Pha đều cảm nhận} | Default for design tokens {Mặc định cho design token} |
oklab | Same family as oklch, rectilinear path {Cùng họ OKLCH, đường rectilinear} | When oklch hue path causes unwanted shifts {Khi đường hue oklch gây lệch không mong muốn} |
/* Semantic tints from a single brand hue */
--brand: oklch(0.65 0.20 145);
--brand-subtle: color-mix(in oklch, var(--brand) 15%, oklch(0.98 0 0));
--brand-muted: color-mix(in oklch, var(--brand) 40%, oklch(0.98 0 0));
--brand-emphasis: color-mix(in oklch, var(--brand) 90%, black);
Senior rule {Quy tắc senior}: mix semantic states (
hover,disabled,focus-ring) inoklch; only fall back tosrgbwhen you must match a legacy computed value exactly {pha state semantic (hover,disabled,focus-ring) trongoklch; chỉ fallbacksrgbkhi phải khớp giá trị computed legacy chính xác}.
Relative Color Syntax {Cú pháp relative color}
Relative colors let you derive a new color from an existing one without Sass/Less {Relative color cho derive màu mới từ màu có sẵn mà không cần Sass/Less}:
.card {
--card-bg: oklch(0.22 0.02 260);
border: 1px solid oklch(from var(--card-bg) l c h / 0.4);
}
.link {
--link: oklch(0.65 0.18 250);
color: var(--link);
}
.link:hover {
/* Same hue & chroma, bump lightness */
color: oklch(from var(--link) calc(l + 0.12) c h);
}
.link:visited {
/* Rotate hue 40deg, reduce chroma */
color: oklch(from var(--link) l calc(c * 0.7) calc(h + 40));
}
The from keyword unpacks the base color into channels you can mutate with calc() {Từ khóa from giải nén màu gốc thành kênh bạn mutate bằng calc()}. This is how you build composable token systems where --accent is the only hand-authored value {Đây là cách xây hệ token composable mà --accent là giá trị duy nhất viết tay}.
:root {
--accent: oklch(0.87 0.23 120);
--accent-hover: oklch(from var(--accent) calc(l - 0.05) c h);
--accent-muted: oklch(from var(--accent) l calc(c * 0.4) h);
--accent-subtle: oklch(from var(--accent) 0.95 calc(c * 0.15) h);
--accent-border: oklch(from var(--accent) l c h / 0.35);
}
Browser support for relative colors is solid in Chromium and Safari (2024+) {Hỗ trợ relative color ổn trên Chromium và Safari (2024+)}; plan a fallback (see below) {lên kế hoạch fallback (xem dưới)}.
Wide Gamut: display-p3 and color-gamut Queries {Wide gamut: display-p3 và color-gamut query}
On P3-capable displays, you can author colors outside sRGB {Trên màn P3, bạn viết màu ngoài sRGB}:
.hero-gradient {
/* sRGB-safe fallback first */
background: linear-gradient(135deg, #4d7c0f, #84cc16);
/* Vivid P3 greens on capable hardware */
background: linear-gradient(
135deg,
color(display-p3 0.35 0.65 0.12),
color(display-p3 0.55 0.85 0.05)
);
}
Gate enhancements with @media (color-gamut) {Khoá enhancement bằng @media (color-gamut)}:
@supports (color: color(display-p3 1 1 1)) {
@media (color-gamut: p3) {
:root {
--accent: color(display-p3 0.82 1 0.04);
--brand-red: color(display-p3 1 0.22 0.18);
}
}
}
| Query | Meaning {Nghĩa} |
|---|---|
(color-gamut: srgb) | Standard gamut only {Chỉ gamut chuẩn} |
(color-gamut: p3) | Display P3 or wider {Display P3 hoặc rộng hơn} |
(color-gamut: rec2020) | HDR / wide cinema gamut {HDR / gamut cinema rộng} |
Practical note {Lưu ý thực tế}: pair P3 accents with OKLCH neutrals {Ghép accent P3 với neutral OKLCH}. Neutrals rarely benefit from wide gamut; saturated brand colors do {Neutral hiếm khi lợi từ wide gamut; màu brand bão hòa thì có}.
light-dark(): One Declaration, Two Themes {light-dark(): một khai báo, hai theme}
If you ever add a light theme (this site is dark-only today), light-dark() reduces duplication {Nếu thêm light theme (site này dark-only hiện tại), light-dark() giảm trùng lặp}:
:root {
color-scheme: light dark;
--text: light-dark(oklch(0.15 0.01 260), oklch(0.92 0.01 260));
--surface: light-dark(oklch(0.98 0.005 260), oklch(0.18 0.01 260));
--border: light-dark(oklch(0.85 0.01 260), oklch(0.32 0.01 260));
}
It respects prefers-color-scheme automatically when color-scheme is set {Tự tôn trọng prefers-color-scheme khi color-scheme được set}. Author both values in OKLCH so the perceptual step between themes stays consistent {Viết cả hai giá trị bằng OKLCH để bước cảm nhận giữa theme nhất quán}.
Gradients: Fix Banding with Interpolation Space {Gradient: sửa banding bằng interpolation space}
Default gradients interpolate in sRGB, which causes visible banding in dark ramps and gray dead zones in hue sweeps {Gradient mặc định nội suy trong sRGB, gây banding trên ramp tối và vùng xám chết trên quét hue}.
/* ❌ sRGB default — muddy purple-gray in the middle */
.bad {
background: linear-gradient(in srgb, blue, yellow);
}
/* ✅ Perceptually smooth hue sweep */
.good {
background: linear-gradient(in oklch longer hue, blue, yellow);
}
/* ✅ Dark UI surface fade — no greenish band */
.surface-fade {
background: linear-gradient(
to bottom,
oklch(0.18 0.01 260),
oklch(0.10 0.01 260)
);
/* Explicit: linear-gradient(in oklch, ...) */
}
The longer hue keyword (where supported) picks the long arc around the hue wheel — useful for rainbow effects without clipping through gray {Từ khóa longer hue (nơi hỗ trợ) chọn cung dài quanh bánh hue — hữu ích cho hiệu ứng cầu vồng không clip qua xám}.
Fallback Strategy with @supports {Chiến lược fallback với @supports}
Ship OKLCH progressively {Deploy OKLCH dần dần}. Pattern:
:root {
/* Layer 1: sRGB hex — every browser */
--accent: #c8ff00;
--surface: #111111;
--text: #e5e5e5;
/* Layer 2: OKLCH — modern browsers override */
--accent: oklch(0.87 0.23 120);
--surface: oklch(0.18 0.01 260);
--text: oklch(0.92 0.01 260);
}
@supports (color: oklch(0 0 0)) {
:root {
--accent: oklch(0.87 0.23 120);
--surface: oklch(0.18 0.01 260);
--text: oklch(0.92 0.01 260);
}
}
@supports (color: color-mix(in oklch, white, black)) {
.button:hover {
background: color-mix(in oklch, var(--accent) 85%, black);
}
}
@supports (color: color(display-p3 1 1 1)) {
@media (color-gamut: p3) {
:root {
--accent: color(display-p3 0.82 1 0.04);
}
}
}
Because CSS custom properties cascade, the last supported declaration wins {Vì custom property cascade, khai báo được hỗ trợ cuối thắng}. Put hex first as universal floor, OKLCH second as upgrade {Đặt hex trước làm sàn phổ quát, OKLCH sau làm nâng cấp}.
For relative colors, provide a static fallback token {Với relative color, cung cấp token fallback tĩnh}:
.link {
color: var(--link, oklch(0.65 0.18 250));
}
@supports (color: oklch(from blue l c h)) {
.link:hover {
color: oklch(from var(--link) calc(l + 0.12) c h);
}
}
Migration Checklist for Design Systems {Checklist migration cho design system}
- Audit semantic colors — list every
--color-*token and its HSL/hex source {Audit màu semantic — liệt kê mọi token--color-*và nguồn HSL/hex} - Convert to OKLCH — use DevTools color picker or
oklch()directly; do not round-trip through HSL {Convert sang OKLCH — dùng color picker DevTools hoặcoklch()trực tiếp; không round-trip qua HSL} - Rebuild ramps — equal L steps, fixed C, rotated H for hue families {Rebuild ramp — bước L đều, C cố định, xoay H cho họ hue}
- Replace hand-mixed states with
color-mix(in oklch, …){Thay state pha tay bằngcolor-mix(in oklch, …)} - Fix gradients — add
in oklchto every multi-hue or dark gradient {Sửa gradient — thêmin oklchcho mọi gradient đa hue hoặc tối} - Add P3 accents behind
@media (color-gamut: p3){Thêm accent P3 sau@media (color-gamut: p3)} - Verify contrast — OKLCH is better, not automatic AAA {Verify contrast — OKLCH tốt hơn, không tự động AAA}
What to Use When {Dùng gì khi nào}
| Task | Reach for {Dùng} |
|---|---|
| Author a brand palette | oklch(L C H) with locked L/C {oklch(L C H) khóa L/C} |
| Hover / disabled / tint | color-mix(in oklch, …) |
| Derive variants from one token | oklch(from var(--x) …) |
| Vivid marketing hero | color(display-p3 …) + gamut query |
| Dark-to-darker gradient | linear-gradient(in oklch, …) |
| Legacy browser support | Hex fallback + @supports override |
| Quick one-off in DevTools | Still fine — but paste as oklch() into source |
Closing Thought {Lời kết}
Color is one of the few CSS domains where the syntax you choose changes the physics of what users see {Màu sắc là một trong ít lĩnh vực CSS mà cú pháp bạn chọn đổi vật lý của thứ user nhìn}. sRGB hex got us here {Hex sRGB đưa chúng ta tới đây}; OKLCH and color-mix() are how design systems grow up {OKLCH và color-mix() là cách design system trưởng thành}.
Start with one token — your --accent or --surface ramp — convert it, mix states in OKLCH, and compare side by side with the old HSL version {Bắt đầu với một token — --accent hoặc ramp --surface — convert, pha state trong OKLCH, so sánh cạnh bản HSL cũ}. The difference is immediate and hard to unsee {Khác biệt thấy ngay và khó quên}.