约 7 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
典型症状是:设计系统里挑了一档灰做次要文字,视觉评审没人反对,审计工具直接判不过 AA。症结在于 WCAG 从不比较色号的深浅——它先把每个通道的 8 位数值做伽马展开,再按 0.2126 / 0.7152 / 0.0722 加权成一个 0 到 1 的相对亮度 L,最后比 (L1 + 0.05) / (L2 + 0.05)。所以 #999999 的问题不是「灰得不够狠」,而是它的 L 离纯白太近;同一档灰挪到黑底上,对比度立刻跳到 20:1 以上。
const srgb = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const lum = hex => {
const n = parseInt(hex.replace("#", ""), 16);
return 0.2126 * srgb(n >> 16) + 0.7152 * srgb((n >> 8) & 255) + 0.0722 * srgb(n & 255);
};
const ratio = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
ratio("#999999", "#ffffff"); // 2.85 AA 不通过
ratio("#767676", "#ffffff"); // 4.54 AA 通过
ratio("#4f8cff", "#000000"); // 6.53 AA 通过,差一点到 AAA正文的标准是 4.5:1,AAA 要求 7:1;字号达到 24px,或 19px 且加粗时,标准放宽到 3:1,AAA 对应 4.5:1。别把这条规则简化成「一律 4.5」——非文本元素适用的是另一条线:图标、输入框边框、图表里相邻的色块、焦点圈,只要能识别就需要 3:1。一个用 #e5e7eb 画在白底上的输入框边框只有 1.24:1,等于没画。
下面六个组合是实际项目里最常见的失败点。比值都按上文公式算过,改法只动一个变量,照抄即可。
| 症状 | 真实比值 | 修法 |
|---|---|---|
| 正文灰 #999999 配白底 | 2.85:1,AA 不通过 | 压到 #767676(4.54:1),或直接换更深的灰阶 |
| 灰底卡片里用 #9ca3af | 2.31:1(#9ca3af on #f3f4f6) | 背景比文字还浅时把文字压深,别只调背景 |
| 品牌蓝 #4f8cff 做按钮底配白字 | 3.22:1,未过 AA | 底色压深到 #4173d1(4.58:1),色相不变 |
| 白字压在 50% 透明蓝上 | 合成后 1.73:1,比原色更差 | 先与背景合成再算,别拿原色值直接判 |
| 图标 / 输入框边框用 #e5e7eb | 1.24:1 | 非文本按 3:1,把边框色改成 #9ca3af 以下更深 |
| 深色主题沿用浅色主题的灰 | #94a3b8 on #0f172a = 6.96:1(通过) | 两套主题各算一遍,别假设「浅色过了深色就过」 |
rgba(79,140,255,0.5) 这类半透明色不能直接拿去算对比度,因为 WCAG 比的是「最终显示出来的那个颜色」。正确做法是先按 alpha 把前景与背景合成成不透明色再算:#4f8cff 以 50% 叠在白底上合成出 #a7c6ff,对比度只有 1.73:1,比不透明时的 3.22:1 更差——透明度不会救场,只会让问题更隐蔽。毛玻璃、遮罩层、hover 态的半透明底色都要过这一步;毛玻璃还要按最坏背景评估,因为背后的图不可控。
const over = (fg, bg, alpha) => {
const p = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const [f, b] = [p(fg), p(bg)];
return "#" + f.map((v, i) =>
Math.round(v * alpha + b[i] * (1 - alpha)).toString(16).padStart(2, "0")).join("");
};
over("#4f8cff", "#ffffff", 0.5); // → "#a7c6ff"
ratio("#a7c6ff", "#ffffff"); // → 1.73 白字压在上面不可读const pairs = [["#6b7280","#ffffff"],["#9ca3af","#ffffff"],["#6b7280","#f3f4f6"],["#4f8cff","#ffffff"]];
pairs.forEach(([fg, bg]) => {
const r = ratio(fg, bg);
console.log(`${fg} on ${bg} = ${r.toFixed(2)} ${r >= 4.5 ? "AA ok" : "fail"}`);
});
// #6b7280 on #ffffff = 4.83 AA ok
// #9ca3af on #ffffff = 2.54 fail
// #6b7280 on #f3f4f6 = 4.39 fail ← 同一个色,换个底色就掉线
// #4f8cff on #ffffff = 3.22 fail对比度只解决「看得清」,不解决「看得懂」。仍需人工确认的有三类:色觉障碍用户能否区分状态色——红与绿表示错误与成功在 deuteranopia 下几乎同色;只靠颜色传达的信息——红点、彩色标签、图表里靠颜色区别的两条线,要补图标或文字;以及深色模式必须在真实背景上重算一遍,从浅色主题继承来的半透明层在深底上基本都失效。工具替代的是算术,判断替代不了。
第一,默认态过了还要跑 hover / focus / active / disabled,多数主题只在前两个上达标。第二,深色模式单独跑一遍,不要假设「浅色过了深色就过」——阈值不变但 L 值全变了。第三,图片上的文字与图标,背景是像素不是纯色:取色后按背景最亮与最暗两个极值算,区间内只要有一个不达标就该加半透明遮罩或改用图片打码区域来压底色。
先看结论:把前景与背景的每个 RGB 通道做伽马展开,按 0.2126 / 0.7152 / 0.0722 加权得到相对亮度,再用 (L1 + 0.05) / (L2 + 0.05)。手算远不如工具快——两个色值贴进 WCAG 对比度检测,直接读比值和 AA / AAA 判定,顺带看它给出的修正建议色。
不能直接算,必须先合成。rgba 色与背后颜色按 alpha 混合后才形成实际显示色,把合成结果当成普通不透明色再算比值。例如 #4f8cff 叠 50% 在白底上得到 #a7c6ff,对比度只有 1.73:1。毛玻璃遮罩背后内容不可控时,按最亮的背景算才是安全做法。
够,但要先确认字号真的达标:24px,或 19px 且加粗。中文正文的实际显示高度比数字小,同字号下笔画更密,通常从严按 4.5:1 处理。此外 3:1 这条线专属于非文本元素(图标、边框、焦点圈),不要拿它去省正文的对比度。
不能,达标只是文本可读性这一项。还要满足键盘可操作、焦点可见、不只靠颜色传达信息(色觉障碍)、以及图片文本有替代方案。工具能替你算数值,替代不了这四类判断。