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.
Vấn đề màu mà ít ai nói tới
Bạn chọn #3b82f6 cho primary, #22c55e cho success, và #eab308 cho warning. Chúng trông ổn cạnh nhau trong Figma. Deploy lên production, 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.
Đó không phải lỗi thiết kế. Đó là lỗi không gian màu.
Suốt hai thập kỷ, frontend engineer sống trong sRGB biểu diễn bằng hex, rgb(), và hsl(). Các định dạng này tiện, 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. 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.
Bài viết này về chọn và pha màu đúng cách, không phải về performance selector hay liệt kê tính năng.
Demo trực tiếp
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ố.
Mở demo đầy đủ:
Thử điều này trong demo: khóa L = 0.75, quét hue trong OKLCH vs HSL ở “50% lightness”. Dải OKLCH giữ độ sáng đều; dải HSL thì không.
Tại sao Hex, RGB, và HSL không đủ
sRGB là định dạng giao hàng, không phải không gian thiết kế
Hex và rgb() là giá trị sRGB đã mã hóa — ba kênh 8-bit bị giới hạn trong tam giác nhỏ trên biểu đồ sắc độ CIE. Chúng cho biết cách điều khiển pixel, không phải màu trông sáng thế nào.
| Format | Nó cho bạn gì | Nó che giấu gì |
|---|---|---|
#rrggbb | Bộ ba sRGB gọn | Không có ngữ nghĩa độ sáng |
rgb(r g b) | Giống hex, dễ đọc | Bước không đều cảm giác lệch |
hsl(h s% l%) | Tọa độ cực trong sRGB | L không phải độ sáng cảm nhận |
Insight quan trọng: HSL được thiết kế để bạn viết
hsl(120 100% 50%)và có “xanh lá thuần”. Nó không bao giờ được thiết kế để50%lightness qua các hue trông sáng bằng nhau.
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 system sinh scale bằng hsl(var(--hue) X% Y%) kế thừa bias này. Ramp xám “neutral” có thể ổn, nhưng palette semantic (success, warning, danger) sẽ không cân bằng ở cùng bước số.
Gamut hạn chế trên màn hình hiện đại
Hầu hết laptop và điện thoại từ 2020 render Display P3 — khoảng 25% màu hơn sRGB. Khi chỉ viết #rrggbb, bạn bỏ phần dư đó. Xanh lá và đỏ brand sống động bị clip hoặc xám khi convert.
Không gian màu: mô hình tư duy senior
Nghĩ theo ba lớp:
- Working space — nơi bạn thiết kế (OKLCH, OKLAB, LCH)
- Interpolation space — nơi gradient và pha màu diễn ra
- Output space — màn hình hiển thị gì (sRGB, P3)
CSS giờ expose cả ba trong cú pháp.
OKLAB và OKLCH
OKLAB (Oklab) là không gian đồng đều cảm nhận từ khoa học màu OKLab. OKLCH là dạng trụ — thay thế HSL bạn thực sự cần:
| Channel | Range (typical) | Nghĩa |
|---|---|---|
| L | 0 – 1 (or 0% – 100%) | Độ sáng cảm nhận |
| C | 0 – ~0.4 | Chroma (cường độ bão hòa) |
| H | 0 – 360 | 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 */
}
Tại sao OKLCH hơn OKLAB? 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.
Xây palette accessible
WCAG contrast tính trên relative luminance, không phải HSL L. OKLCH L tương quan gần luminance hơn HSL L. Quy tắc ngón tay cho dark UI:
- Text trên
oklch(0.15 …)→ nhắm text ≥oklch(0.85 …) - Giữ C thấp (≤
0.03) cho neutral; tăng C cho accent brand - 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);
Luôn verify bằng contrast checker — OKLCH đưa bạn 90% đường đi mà không đoán mò.
color-mix(): ngừng chọn midpoint bằng tay
Trước color-mix(), hover state là #1a1a1a copy từ Figma blend. 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);
}
Pha trong không gian màu nào?
Từ khóa in <color-space> không phải trang trí. Nó đổi đường nội suy:
| Mix space | Kết quả | Dùng khi |
|---|---|---|
srgb | Nhanh, quen, midpoint thường đục | Compat legacy, tint nhẹ |
hsl | Hue xoay không đoán; lightness không đều | Hiếm — ưu tiên oklch |
oklch | Pha đều cảm nhận | Mặc định cho design token |
oklab | Cùng họ OKLCH, đường rectilinear | 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);
Quy tắc senior: pha state semantic (
hover,disabled,focus-ring) trongoklch; chỉ fallbacksrgbkhi phải khớp giá trị computed legacy chính xác.
Cú pháp relative color
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));
}
Từ khóa from giải nén màu gốc thành kênh bạn mutate bằng calc(). Đâ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);
}
Hỗ trợ relative color ổn trên Chromium và Safari (2024+); lên kế hoạch fallback (xem dưới).
Wide gamut: display-p3 và color-gamut query
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)
);
}
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 | Nghĩa |
|---|---|
(color-gamut: srgb) | Chỉ gamut chuẩn |
(color-gamut: p3) | Display P3 hoặc rộng hơn |
(color-gamut: rec2020) | HDR / gamut cinema rộng |
Lưu ý thực tế: Ghép accent P3 với neutral OKLCH. Neutral hiếm khi lợi từ wide gamut; màu brand bão hòa thì có.
light-dark(): một khai báo, hai theme
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));
}
Tự tôn trọng prefers-color-scheme khi color-scheme được set. Viết cả hai giá trị bằng OKLCH để bước cảm nhận giữa theme nhất quán.
Gradient: sửa banding bằng interpolation space
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, ...) */
}
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.
Chiến lược fallback với @supports
Deploy OKLCH dần dần.
: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);
}
}
}
Vì custom property cascade, khai báo được hỗ trợ cuối thắng. Đặt hex trước làm sàn phổ quát, OKLCH sau làm nâng cấp.
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);
}
}
Checklist migration cho design system
- Audit màu semantic — liệt kê mọi token
--color-*và nguồn HSL/hex - Convert sang OKLCH — dùng color picker DevTools hoặc
oklch()trực tiếp; không round-trip qua HSL - Rebuild ramp — bước L đều, C cố định, xoay H cho họ hue
- Thay state pha tay bằng
color-mix(in oklch, …) - Sửa gradient — thêm
in oklchcho mọi gradient đa hue hoặc tối - Thêm accent P3 sau
@media (color-gamut: p3) - Verify contrast — OKLCH tốt hơn, không tự động AAA
Dùng gì khi nào
| Task | Dùng |
|---|---|
| Author a brand palette | 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 |
Lời kết
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. Hex sRGB đưa chúng ta tới đây; OKLCH và color-mix() là cách design system trưởng thành.
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ũ. Khác biệt thấy ngay và khó quên.