Bỏ qua, sang nội dung chính
← Về danh sách

· Working ·

Thang màu kiểu Radix: đánh số theo khoảng cách để dark mode tự đúng

Thang màu kiểu Radix đánh số bậc theo khoảng cách so với nền thay vì theo độ sáng. Bài này so sánh với cách đánh số của Tailwind, chỉ ra CSS thật của site này, và giải thích vì sao lớp token ngữ nghĩa không cần một dòng override nào cho dark mode.

Thang màu kiểu Radix đánh số bậc theo khoảng cách so với nền. Bậc thấp nhất luôn là màu nền, bậc cao nhất luôn là chữ đậm nhất, ở cả chế độ sáng lẫn tối. Component chỉ gọi tên bậc, còn giá trị thật đổi theo theme ở một chỗ, nên dark mode gần như tự đúng.

Tôi là Hai Le, UI/UX designer biết code. Thang xám của site này dựng theo quy ước đó, và mọi đoạn CSS dưới đây lấy từ app/globals.css của repo.

Thang màu Radix khác thang màu Tailwind ở đâu?

Tài liệu Tailwind ghi rằng mỗi màu trong bảng mặc định có 11 bậc, "50 là sáng nhất và 950 là tối nhất". Con số mô tả độ sáng, nên gray-50 vẫn gần trắng khi người dùng bật dark mode. Muốn đổi màu theo theme, bạn viết thêm class như ví dụ trong tài liệu dark mode của Tailwind: bg-white dark:bg-gray-800.

Radix Colors có 12 bậc mỗi thang, và tài liệu gán cho từng nhóm bậc một việc:

Bậc RadixDùng cho
1–2Nền ứng dụng, nền component nhẹ
3–5Nền component: bình thường, hover, đang chọn
6–8Viền: viền nhẹ, viền tương tác, viền đậm và vòng focus
9–10Màu đặc, rồi trạng thái hover của màu đặc
11–12Chữ: tương phản thấp và tương phản cao

Radix áp biến của thang sáng lên :root và các class .light, .light-theme, còn biến của thang tối lên .dark, .dark-theme. Cùng tên biến, khác giá trị, nên gray-1 ở chế độ tối vẫn là nền.

Đánh số theo khoảng cách so với nền nghĩa là gì?

Hãy hình dung con số là số bước đi từ nền về phía chữ. Bậc 50 là đứng trên nền, bậc 950 là chữ đậm nhất. Trên nền trắng, đi xa khỏi nền là tối dần. Trên nền tối, đi xa khỏi nền là sáng dần.

Site này giữ tên bậc 50–950 cho quen tay, nhưng nghĩa của con số là của Radix. Đây là thang xám thật, cả hai chế độ:

BậcSángTốiVai trò
50#ffffff#111111Nền trang, nền thẻ
100#f8f8f8#191919Mảng xám, hover
200#efefef#242424Đường kẻ hairline
300#e0e0e0#2e2e2eViền ô nhập
400#b8b8b8#4a4a4aĐồ hoạ, trạng thái vô hiệu
500#9a9a9a#5e5e5eTrang trí
600#717171#868686Chữ phụ bậc ba
700#595959#a8a8a8Chữ phụ bậc hai
950#000000#edededChữ chính

Mỗi hàng giữ cùng một vai trò dù giá trị đảo chiều. Bậc 800 và 900 đảo theo cùng quy luật, tôi bỏ khỏi bảng cho gọn.

CSS của một thang màu kiểu Radix trông thế nào?

Thang xám nằm ở lớp 0 (raw) và là thang duy nhất đổi giá trị khi có class .dark. Rút gọn từ app/globals.css:

:root {
  --gray-50: #ffffff;
  --gray-100: #f8f8f8;
  --gray-600: #717171; /* subtle-foreground */
  --gray-700: #595959; /* muted-foreground */
  --gray-950: #000000;
}

.dark {
  --gray-50: #111111;
  --gray-100: #191919;
  --gray-600: #868686;
  --gray-700: #a8a8a8;
  --gray-950: #ededed;
}

Lớp 1 (alias) và lớp 2 (semantic) chỉ trỏ vào tên bậc. Chúng không biết theme đang là gì:

:root,
.dark {
  --neutral-50: var(--gray-50);
  --neutral-950: var(--gray-950);

  --background: var(--neutral-50);
  --foreground: var(--neutral-950);
  --muted: var(--neutral-100);
  --muted-foreground: var(--neutral-700);
  --subtle-foreground: var(--neutral-600);
  --border: var(--neutral-200);
}

Selector :root, .dark có lý do. Biến CSS được tính tại phần tử khai báo nó. Nếu chỉ khai ở :root, một khối .dark lồng bên trong sẽ thừa hưởng giá trị đã tính theo màu sáng. Khai cho cả hai selector buộc .dark tính lại cả chuỗi theo thang xám của nó.

Component chỉ viết bg-background text-foreground hoặc text-muted-foreground. Các component tôi tự viết không có class dark: nào để đổi màu xám. Vài class dark: còn lại nằm trong primitive shadcn chép nguyên. Nếu bạn chưa quen ba lớp raw, alias, semantic, đọc bài Design token là gì trước rồi quay lại.

Vì sao tôi chỉ dùng quy ước Radix cho thang xám?

Thang xám là thang duy nhất phải đảo hoàn toàn. Mọi bề mặt và mọi bậc chữ đều dựng trên nó, nên cho nó tự đảo ở lớp 0 tiết kiệm nhiều dòng nhất.

Magenta #ff009d thì chỉ cần sáng hơn một chút để đọc được trên nền tối. Nên thang --brand-* giữ giá trị cố định kiểu Tailwind, và dark mode chọn bậc khác ở lớp 2:

.dark {
  --primary: var(--primary-400);       /* #ff3fb2 */
  --primary-text: var(--primary-400);
  --primary-foreground: #ffffff;
}

Toàn bộ phần override cho dark mode ở lớp 2 của site này nằm trong một khối khoảng hai mươi dòng như trên, gồm magenta, màu lỗi, footer và màu biểu đồ.

Đặt bậc chữ trong thang thế nào để đạt WCAG?

Tôi đặt hai bậc chữ phụ theo ngưỡng tương phản trước, rồi mới chỉnh bằng mắt. WCAG 2.2, tiêu chí 1.4.3, yêu cầu chữ thường đạt ít nhất 4.5:1. Ở site này, ràng buộc chặt nhất là chữ trên mảng xám muted.

Các số dưới đây tôi tính lại bằng công thức độ chói tương đối của WCAG:

Cặp màuChế độTỉ lệChữ thường (4.5:1)
#595959 trên #ffffffSáng7.00:1Đạt
#717171 trên #ffffffSáng4.88:1Đạt
#717171 trên #f8f8f8Sáng4.60:1Đạt, sát ngưỡng
#9a9a9a trên #ffffffSáng2.81:1Trượt
#a8a8a8 trên #111111Tối7.94:1Đạt
#868686 trên #111111Tối5.19:1Đạt
#868686 trên #191919Tối4.83:1Đạt
#5e5e5e trên #111111Tối2.91:1Trượt

Bậc 500 trượt ở cả hai chế độ, nên quy tắc chỉ cần viết một lần: neutral-500 và neutral-400 không phải màu chữ.

Radix có cam kết riêng cho bậc chữ: tài liệu ghi bậc 11 và 12 đạt Lc 60 và Lc 90 theo thang APCA trên nền bậc 2 cùng thang. APCA là một phương pháp đo khác WCAG 2.2, nên đừng so con số Lc với tỉ lệ 4.5:1.

Những lỗi hay gặp khi dựng thang màu cho dark mode?

Đảo màu bằng tay ở từng component. Viết text-gray-500 dark:text-gray-400 ở mọi chỗ dễ sót một chỗ, và chỗ sót đó chỉ lộ ra khi ai đó bật dark mode. Cho thang tự đảo ở lớp 0 thì component không có gì để sót.

Chép giá trị sáng sang nền tối. Nếu một component cứng giá trị #717171 thay vì gọi token, ở dark mode nó nằm trên #191919 và chỉ còn 3.60:1. Chữ xám nhạt trên mảng xám là lỗi rất dễ lọt qua vì mắt thường vẫn thấy đọc được.

Đảo cả thang thương hiệu. Người dùng nhận ra sản phẩm qua màu thương hiệu, nên giữ giá trị của nó cố định. Ở site này, dark mode chỉ đổi từ bậc 500 sang 400, tức từ #ff009d sang #ff3fb2.

Quên ghi quy ước ra chữ. Người quen Tailwind sẽ đoán neutral-50 là màu sáng nhất. AI đọc file của bạn cũng đoán vậy. Site này ghi quy ước trong cả comment CSS lẫn DESIGN.md. Bài DESIGN.md là gì kể vì sao tôi phải viết hẳn một mục cho chuyện này.

Nên bắt đầu từ đâu nếu team đang dùng thang kiểu Tailwind?

Bắt đầu với thang xám, giữ nguyên các thang khác.

  1. Liệt kê mọi chỗ dùng xám theo vai trò: nền, mảng, đường kẻ, viền ô nhập, ba bậc chữ.
  2. Gán mỗi vai trò cho một bậc, đánh số theo khoảng cách so với nền.
  3. Viết giá trị sáng, rồi chọn giá trị tối cho cùng bậc sao cho cặp chữ trên nền vẫn đạt 4.5:1.
  4. Cho lớp semantic trỏ vào bậc, rồi xoá dần các class dark: cho màu xám trong component.

Ở Joyme, bảng màu tôi dựng gồm thang tím, thang cam và một thang xám. Bạn có thể xem phần hệ thống thiết kế trong case study Joyme. Phần trả lời nhanh về dark mode nằm ở bài Câu hỏi thường gặp về dark mode, và cách đặt tên token ở bài Câu hỏi thường gặp về token. Nếu team bạn cần người dựng design system từ token tới code, xem thêm về tôi ở trang giới thiệu.

Nguồn

Câu hỏi thường gặp

Thang màu Radix có bao nhiêu bậc?
Radix Colors dùng 12 bậc cho mỗi thang. Bậc 1–2 làm nền, 3–5 làm nền component, 6–8 làm viền, 9–10 là màu đặc, 11–12 dành cho chữ. Site này dùng 11 bậc kiểu Tailwind (50 đến 950) nhưng mượn cách hiểu của Radix cho thang xám.
Tailwind và Radix đánh số thang màu khác nhau thế nào?
Trong bảng màu mặc định của Tailwind, 50 là sáng nhất và 950 là tối nhất, cố định ở mọi theme. Trong Radix, cùng một tên biến đổi giá trị theo theme, nên bậc 1 luôn là nền và bậc 12 luôn là chữ đậm nhất.
Có nên đổi mọi thang màu sang kiểu Radix không?
Tôi chỉ đổi thang xám. Thang thương hiệu và thang trạng thái giữ cách đánh số của Tailwind, và dark mode chọn một bậc khác cho chúng ở lớp token ngữ nghĩa. Số dòng phải sửa ít hơn nhiều so với đảo cả bảng.
Dùng thang kiểu Radix có tự đảm bảo tương phản không?
Không tự động. Bạn vẫn phải đo từng cặp chữ và nền ở cả hai chế độ. Ở site này, hai bậc chữ phụ được đặt theo ngưỡng 4.5:1 trên cả nền trắng lẫn mảng xám, rồi mới chọn giá trị tối tương ứng.