Adaptive Contrast: Tự động điều chỉnh foreground theo background
Bất cứ lúc nào background thay đổi màu — dark mode, surface tinted, component đặt trên ảnh, brand color — câu hỏi ngay lập tức đặt ra là: text màu gì cho đủ đọc? Câu hỏi này có câu trả lời chính xác theo toán học, và có thể được tự động hoá hoàn toàn.
Heading — primary text
Secondary body — muted paragraph text
Caption · tertiary · meta information
| Element | Token | Ratio | WCAG |
|---|
Design system gray scale
Kéo slider để thay đổi background — foreground và contrast ratio tự động điều chỉnh theo
Contrast ratio — con số cần biết
WCAG (Web Content Accessibility Guidelines) định nghĩa contrast ratio theo thang từ 1:1 (không có contrast) đến 21:1 (đen trên trắng). Tiêu chuẩn cụ thể:
| Mức | Tỉ lệ tối thiểu | Áp dụng cho |
|---|---|---|
| AA (bắt buộc) | 4.5:1 | Text thường (dưới 18pt) |
| AA Large | 3:1 | Text lớn (≥ 18pt regular hoặc ≥ 14pt bold) |
| AAA (nâng cao) | 7:1 | Text thường — level accessibility cao nhất |
4.5:1 là ngưỡng tối thiểu cho hầu hết use case. Dưới ngưỡng này, người dùng có thị lực kém, đọc màn hình trong điều kiện ánh sáng mạnh, hoặc màn hình chất lượng thấp sẽ gặp khó khăn.
Cách tính — relative luminance
Contrast ratio không tính trực tiếp từ giá trị RGB. Nó tính từ relative luminance — thước đo độ sáng của màu theo cách mắt người cảm nhận, không phải giá trị số học đơn thuần.
Công thức WCAG:
luminance = 0.2126×R + 0.7152×G + 0.0722×B
Trong đó R, G, B đã được linearize (khử gamma sRGB). Hệ số 0.2126/0.7152/0.0722 phản ánh thực tế mắt người nhạy cảm nhất với xanh lá (green), ít nhất với xanh lam (blue).
Sau khi có luminance của background (L₁) và foreground (L₂):
contrast ratio = (max(L₁,L₂) + 0.05) / (min(L₁,L₂) + 0.05)
Điểm chuyển đổi: Khi luminance của background khoảng 0.179 (tương đương gray-60 trong design system này), white và black text cho cùng contrast ratio. Tối hơn mức đó → dùng white. Sáng hơn → dùng black.
Tại sao không chỉ cần một rule “light = dark text, dark = light text”
Rule đơn giản đó hoạt động tốt cho hai màu cực đoan (thuần trắng / thuần đen). Nhưng thực tế design system có nhiều surface level: gray-0, gray-20, gray-50, gray-70… mỗi mức cần foreground color khác nhau.
Thêm vào đó, brand color hoặc màu do user chọn (avatar background, label color, theme color) không nằm trong palette có sẵn. Nếu không tính toán, màu text sẽ bị hardcode và dễ gây lỗi contrast.
Design token approach — cách design system này hoạt động
Hệ thống semantic token trong design system này đã giải quyết vấn đề bằng cách pair sẵn background và foreground:
--bg-neutral-primary(gray-0) →--text-neutral-primary(gray-999): contrast 17.7:1 ✓--bg-neutral-secondary(gray-10) →--text-neutral-primary(gray-999): contrast 14.2:1 ✓--bg-neutral-tertiary(gray-20) →--text-neutral-primary(gray-999): contrast 11.3:1 ✓
Mỗi cặp đã được kiểm tra và guarantee pass WCAG AAA. Khi component chỉ dùng token, không bao giờ xảy ra lỗi contrast — vì pair đã được validate từ design side.
Vấn đề phát sinh khi: background là màu tùy chỉnh (brand accent, dynamic theme, user avatar color, ảnh nền). Lúc này token không cover và cần tính toán runtime.
Ba approach để tự động hoá
Approach 1 — Token pairs (hiện tại)
Mọi surface được define sẵn cùng foreground của nó. Component nhận variant prop và apply đúng token pair.
Ưu điểm: Zero runtime cost, không phụ thuộc JS, guarantee đúng theo design. Nhược điểm: Không cover màu ngoài palette. Thêm màu mới phải cập nhật token manual.
Approach 2 — CSS color-contrast() (tương lai)
CSS đang có proposal cho hàm native:
color: color-contrast(var(--surface-color) vs black, white);
Browser tự tính và chọn màu đủ contrast nhất. Không cần JS, không cần token pair.
Hiện trạng: Đang trong quá trình standardize, chưa có browser support ổn định (tính đến 2025). Cần polyfill hoặc fallback.
Approach 3 — JS luminance computation (linh hoạt nhất)
Tính relative luminance tại runtime, so sánh contrast với black và white, chọn foreground tốt hơn. Approach này dùng được cho bất kỳ màu nào — brand, user-generated, dynamic.
Đây là cách demo phía trên hoạt động. Có thể mở rộng để không chỉ chọn giữa black/white mà chọn từ toàn bộ palette token để foreground phù hợp hơn về mặt visual.
Chọn foreground từ palette thay vì chỉ black/white
Khi background là màu accent (ví dụ blue-60), foreground thuần white có thể pass WCAG nhưng trông “flat”. Một cách tinh tế hơn: thay vì chọn giữa hai cực, chọn từ palette có sẵn — ví dụ blue-10 trên blue-80. Contrast đủ, màu vẫn trong hệ thống.
Logic:
- Tính luminance background
- Với mỗi màu trong palette: tính contrast ratio
- Chọn màu đủ AA (4.5:1) và gần nhất về tone với background (cùng hue family nếu có)
Cách này đòi hỏi nhiều token hơn nhưng cho kết quả visual tốt hơn nhiều so với jump thẳng sang black/white.
Edge cases cần chú ý
Màu trung gian (mid-tone trap): Màu xung quanh luminance 0.179 — như gray-50 đến gray-60 — không đủ contrast với cả black lẫn white ở mức AAA. Phải accept AA (4.5:1) hoặc tránh dùng làm surface cho text quan trọng.
Màu bão hoà cao (saturated colors): Màu đỏ thuần hoặc xanh thuần có luminance thấp mặc dù trông “sáng”. Red (#FF0000) chỉ có luminance 0.213 — gần ngưỡng mid-tone. Text đen trên red đạt ~4.0:1, không pass AA cho text thường.
Transparency: Màu có opacity thay đổi contrast theo background thực tế — không phải màu hex khai báo. Không thể tính đúng nếu không resolve màu cuối cùng sau compositing.
Ảnh nền: Không có “một màu” để tính. Giải pháp thực tế: thêm scrim (overlay gradient mờ) giữa ảnh và text để guarantee contrast tối thiểu, thay vì cố tính contrast từ ảnh.
Không chỉ primary text — cả hierarchy cần re-evaluate
Khi background thay đổi, instinct đầu tiên là kiểm tra primary text. Nhưng một UI thực tế có ít nhất bốn tầng màu chồng lên nhau trên cùng một background:
- Primary text — heading, label chính, giá trị quan trọng
- Secondary text — body paragraph, mô tả, sub-label
- Tertiary / caption — meta, timestamp, ghi chú phụ
- Placeholder / disabled — hint text trong input, state bị vô hiệu hoá
Trong design system, các tầng này thường được implement bằng opacity giảm dần (primary 100% → disabled 22%). Vấn đề: khi background không phải trắng hoặc đen, màu thực sự mà mắt nhìn thấy là kết quả blend của opacity đó với background — không còn là giá trị hex bạn khai báo.
Thực hành thường thấy: primary text pass AAA, secondary pass AA, tertiary bắt đầu vào vùng nguy hiểm, placeholder gần như chắc chắn fail — đặc biệt trên mid-tone background. Demo phía trên hiện audit table cho tất cả tầng này khi kéo slider về vùng L 40–60%.
Border và divider — opacity trick và giới hạn của nó
Border thường được implement bằng rgba(0,0,0,0.1) trên light background — hoạt động tốt khi background là trắng hoặc gần trắng. Khi background tối, cùng opacity đó cho màu gần như không khác background, và divider biến mất.
Rule đơn giản: border opacity cần switch cùng lúc với text — từ đen-opacity sang trắng-opacity khi background vượt qua ngưỡng luminance 0.179.
Phức tạp hơn: divider giữa hai section không phải là “contrast giữa divider và background” mà là “đủ để mắt nhận ra có ranh giới ở đó”. Ngưỡng này thấp hơn text — 1.5:1 đến 2:1 thường là đủ. Nhưng nếu giảm opacity quá thấp để “tinh tế”, divider dễ mất hoàn toàn trên mid-tone.
Shadow — đổi hướng trên dark background
Shadow truyền thống dùng box-shadow: 0 4px 12px rgba(0,0,0,0.1). Trên light background, shadow đen tạo depth rõ ràng. Trên dark background, shadow đen hòa vào nền — card trông phẳng hoàn toàn.
Hai approach:
Invert shadow — dùng rgba(255,255,255,0.08) thay vì rgba(0,0,0,0.1). Tạo “glow” nhẹ thay vì shadow. Hoạt động tốt cho elevation level thấp (card, button), nhưng không tạo được depth mạnh.
Double shadow — kết hợp dark shadow nhỏ (để giữ grounding) và light glow lớn (để tạo separation):
box-shadow:
0 1px 3px rgba(0,0,0,0.3),
0 0 24px rgba(255,255,255,0.06);
Nếu design system chỉ có một shadow token, đây là điểm dễ bị bỏ qua nhất khi chuyển sang dark surface.
Button — compound surface
Button có background riêng của nó, tạo ra một surface độc lập bên trong surface của page. Điều này có nghĩa: button cần tính contrast hai lần.
Primary button — fill với màu ngược chiều background:
- Trên light background: button đen/tối + text trắng
- Trên dark background: button trắng/sáng + text đen
Text bên trong button cần đủ contrast với background của button, không phải của page. Hai lớp hoàn toàn độc lập.
Ghost button — transparent background, chỉ có border và text. Đây là loại button khó nhất vì nó không có surface riêng — text và border phải đủ contrast trực tiếp với page background. Cùng một ghost button với border: 1px solid rgba(0,0,0,0.3) sẽ fail trên mid-tone hoặc dark background.
Secondary button — có tint background (ví dụ rgba(0,0,0,0.05)). Tint tạo ra surface mới, nhưng rất gần với page background. Text bên trong cần được check cả với tint surface lẫn với page (vì tint gần như trong suốt).
Input field — placeholder là điểm fail đầu tiên
Input có ba lớp màu cần xem xét:
-
Background của input — thường khác nhẹ với page background (tinted nhẹ). Cần đủ contrast với page để nhận ra đây là input area.
-
Border — tương tự divider, cần visible nhưng không quá nặng. Thường fail trên mid-tone background.
-
Placeholder text — intentionally muted, thường ở 30–40% opacity. Trên mid-tone background, placeholder gần như chắc chắn fail WCAG AA. Đây là lỗi phổ biến nhất trong form design.
WCAG không yêu cầu placeholder pass 4.5:1 (vì placeholder không phải thông tin bắt buộc để form hoạt động), nhưng nếu placeholder là hướng dẫn duy nhất cho người dùng, thì cần đảm bảo đủ đọc được.
Focus ring — accessibility trap ít ai để ý
Browser mặc định dùng outline: 2px solid blue hoặc tương tự. Trên dark background hoặc background có cùng màu với ring, focus ring biến mất hoặc merge với border.
WCAG 2.2 Focus Appearance (level AA) yêu cầu:
- Focus indicator phải có diện tích ít nhất chu vi component × 2px
- Contrast giữa focused state và unfocused state phải ≥ 3:1
Rule thực tế: dùng double ring — ring trong cùng màu surface + ring ngoài cùng màu accent:
outline: 2px solid var(--accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--bg-surface);
Cách này tạo separation giữa element và focus ring bất kể background là gì.
Disabled state — intentionally thấp nhưng có ngưỡng
Disabled elements được miễn khỏi WCAG contrast requirement (WCAG 2.1 criterion 1.4.3). Nhưng “miễn” không có nghĩa là có thể invisible.
Disabled state cần đủ để người dùng nhận ra “element này có ở đây nhưng không thể tương tác”, không phải “element này không tồn tại”. Contrast khoảng 2.5:1 đến 3:1 là vùng thực tế — đủ muted để trông disabled, đủ visible để không bị nhầm với background.
Vấn đề tương tự opacity cascade: disabled text ở 22% opacity trên neutral background là một chuyện, trên mid-tone background màu cuối cùng có thể merge hoàn toàn với background.
Tích hợp vào design system này
Bước tiếp theo có thể làm:
-
Thêm
on-*token cho mỗi brand color:--on-blue-60: white,--on-red-50: white, v.v. Component dùng token này thay vì hardcode. -
Utility function nhận hex/rgb, trả về token name phù hợp nhất từ palette. Dùng khi màu là dynamic (user input, API data).
-
Lint rule kiểm tra mọi hardcode color pair trong component có pass WCAG không — chạy lúc build.
Phần lớn lỗi contrast trong production không phải từ màu chính — mà từ disabled state, placeholder, label phụ, helper text. Đây là những nơi token “tertiary” và “disabled” hay bị underestimate.
Tham khảo: Hover Micromotion, Button States.