暗色主题好看,但它是无障碍对比度的高发区。很多配色在编辑器里“看着挺清楚”,用审计工具一测却全线不及格——而且不及格的方式很隐蔽。
标准是什么
WCAG 对比度要求(相对亮度比值):
- 正文(< 18pt / 常规字重):至少 4.5:1;
- 大文本(≥ 18pt 或 14pt 粗体):至少 3:1;
- 图标、边框、表单控件等“非文本 UI”:至少 3:1。
暗色主题最常见的失分点是“低透明度灰字”:rgba(255,255,255,0.5) 叠在 #0b0e14 上看着柔和,实测常只有 6.x:1 尚可;但一旦叠加在彩色卡片、或透明度再降一档到 0.4,很容易跌破 4.5。而 text/70、text/50 这类 Tailwind 半透明写法在深底上尤其危险。
一个隐蔽杀手:级联层覆盖
一次真实的暗色站审计里发现:“订阅”按钮文字对比度只有 1.73:1(严重不达标),但组件源码写的是明确的 text-white。根因是一条未分层的裸样式:
a {
color: inherit;
} /* 未分层 → 特异性 + 层级双高压过所有 utilities */
在 Tailwind 的 @layer utilities 下,一条未分层的 a { color: inherit } 会赢过分层工具类,让所有 <a class="text-white"> 静默变成继承来的低对比灰。修复不是加 !important,而是把 html/body/a/:focus-visible 这类基础样式正确放进 @layer base,让 utilities 层能正常覆盖它们:
@layer base {
a {
color: inherit;
}
:root {
/* token 定义刻意保持未分层,确保组件能读取变量 */
}
}
暗色主题 + 半透明文字 + 级联层冲突三者叠加,能制造“肉眼觉得还行、工具判不合格、且不知道为什么”的三重坑。
量化,而不是目测
不要凭眼睛。把计算式对比度纳入流程:
function contrast(rgb1, rgb2) {
const lum = ([r, g, b]) => {
const a = [r, g, b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
};
const [l1, l2] = [lum(rgb1), lum(rgb2)].sort((a, b) => b - a);
return (l1 + 0.05) / (l2 + 0.05);
}
更省事的是直接拿计算样式喂审计:
const cs = getComputedStyle(el);
// 解析 cs.color 与逐层上溯的不透明背景,算对比度;或用 axe-core 的 color-contrast 规则
Lighthouse / axe 的 color-contrast 规则能自动化这件事,但前提是你真的去跑——很多“暗色主题上线即不合格”只是因为从没跑过。
暗色配色的实操建议
- 正文别用纯白
#fff(深底上刺眼),用#e6e6e6一类高灰阶,既舒适又轻松过 4.5:1; - 强调色做双档:一个用于大面积(对比适中),一个用于文字/图标的“加强版”(保证 ≥4.5,图标 ≥3);
- 半透明文字设下限:
muted不低于 0.6 透明度,低于它改用实心低饱和灰; - 边框/分隔线属于非文本 UI,≥3:1,
rgba(255,255,255,0.08)这种几乎不可见的分隔线其实不合规,需要提到 0.12–0.16。
小结
暗色主题的对比度不是“选个深背景”就完事,它要正向处理三件事:低透明度文字的量化、级联层不打架、强调色的可用性双档。把计算式对比度当成和“构建通过”同级的硬性门禁,“看着还行”就不会在真实用户(尤其低视力用户)那里变成“根本看不清”。