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.

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

FormatNó cho bạn gìNó che giấu gì
#rrggbbBộ ba sRGB gọnKhông có ngữ nghĩa độ sáng
rgb(r g b)Giống hex, dễ đọcBước không đều cảm giác lệch
hsl(h s% l%)Tọa độ cực trong sRGBL 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:

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

ChannelRange (typical)Nghĩa
L01 (or 0%100%)Độ sáng cảm nhận
C0 – ~0.4Chroma (cường độ bão hòa)
H0360Gó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.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);

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 spaceKết quảDùng khi
srgbNhanh, quen, midpoint thường đụcCompat legacy, tint nhẹ
hslHue xoay không đoán; lightness không đềuHiếm — ưu tiên oklch
oklchPha đều cảm nhậnMặc định cho design token
oklabCùng họ OKLCH, đường rectilinearKhi đườ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) trong oklch; chỉ fallback srgb khi 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--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);
    }
  }
}
QueryNghĩ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

  1. Audit màu semantic — liệt kê mọi token --color-* và nguồn HSL/hex
  2. Convert sang OKLCH — dùng color picker DevTools hoặc oklch() trực tiếp; không round-trip qua HSL
  3. Rebuild ramp — bước L đều, C cố định, xoay H cho họ hue
  4. Thay state pha tay bằng color-mix(in oklch, …)
  5. Sửa gradient — thêm in oklch cho mọi gradient đa hue hoặc tối
  6. Thêm accent P3 sau @media (color-gamut: p3)
  7. Verify contrast — OKLCH tốt hơn, không tự động AAA

Dùng gì khi nào

TaskDùng
Author a brand paletteoklch(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

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.