jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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()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}.

FormatWhat it gives you {Nó cho bạn gì}What it hides {Nó che giấu gì}
#rrggbbCompact 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ết hsl(120 100% 50%) và có “xanh lá thuần”}. It was never designed so that 50% 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}:

  1. Working space — where you design (OKLCH, OKLAB, LCH) {Working space — nơi bạn thiết kế (OKLCH, OKLAB, LCH)}
  2. Interpolation space — where gradients and mixes happen {Interpolation space — nơi gradient và pha màu diễn ra}
  3. 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}:

ChannelRange (typical)Meaning {Nghĩa}
L01 (or 0%100%)Perceived lightness {Độ sáng cảm nhận}
C0 – ~0.4Chroma (saturation intensity) {Chroma (cường độ bão hòa)}
H0360Hue 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ên oklch(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.050.08) for gray ramps {Bước L đều (0.050.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 spaceResult {Kết quả}Use when {Dùng khi}
srgbFast, familiar, often muddy midpoints {Nhanh, quen, midpoint thường đục}Legacy compat, subtle tints {Compat legacy, tint nhẹ}
hslHue spins unpredictably; lightness uneven {Hue xoay không đoán; lightness không đều}Rarely — prefer oklch {Hiếm — ưu tiên oklch}
oklchPerceptually even blends {Pha đều cảm nhận}Default for design tokens {Mặc định cho design token}
oklabSame 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) in oklch; only fall back to srgb when you must match a legacy computed value exactly {pha state semantic (hover, disabled, focus-ring) trong oklch; chỉ fallback srgb khi 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--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);
    }
  }
}
QueryMeaning {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}

  1. 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}
  2. 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ặc oklch() trực tiếp; không round-trip qua HSL}
  3. 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}
  4. Replace hand-mixed states with color-mix(in oklch, …) {Thay state pha tay bằng color-mix(in oklch, …)}
  5. Fix gradients — add in oklch to every multi-hue or dark gradient {Sửa gradient — thêm in oklch cho mọi gradient đa hue hoặc tối}
  6. Add P3 accents behind @media (color-gamut: p3) {Thêm accent P3 sau @media (color-gamut: p3)}
  7. 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}

TaskReach for {Dùng}
Author a brand paletteoklch(L C H) with locked L/C {oklch(L C H) khóa L/C}
Hover / disabled / tintcolor-mix(in oklch, …)
Derive variants from one tokenoklch(from var(--x) …)
Vivid marketing herocolor(display-p3 …) + gamut query
Dark-to-darker gradientlinear-gradient(in oklch, …)
Legacy browser supportHex fallback + @supports override
Quick one-off in DevToolsStill 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}.