颜色对比度不是算一次就完事:从 4.5 到 1.2,一次 24 组配色的实测
颜色对比度不是算一次就完事:从 4.5 到 1.2,一次 24 组配色的实测
对比度校验器给出的往往是一个两位小数和一个绿勾。看到 4.5 就收工,是这类工具最常见的用法,也是最容易出事的用法——那个数字至少有五处会在你不知情的时候变得不准:先舍入再比较会放过 4.4989,字号与字重决定了阈值到底是 4.5 还是 3,半透明颜色让朴素算法高估两倍以上,父级 opacity 把整块文字一起压暗,同一套 token 换个背景就从 15.8 掉到 1.2。
下面的数字全部来自本机实测:Windows 11、Python 3.13.14、Chrome 无头模式,样本是一个 3183 字节的真实报告样式表(report.css),共抽出 24 组前景/背景组合,其中 20 组文字、4 组边框。
一、公式只有九行,先和权威值对齐
WCAG 2.x 的对比度定义并不复杂,难点全在 sRGB 反伽马那一行:
def lin(c):
c /= 255.0
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def luminance(rgb):
r, g, b = (lin(x) for x in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(fg, bg):
la, lb = luminance(fg), luminance(bg)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
实现完先跑三个已知值:#000000 对白底得 21.000,#767676 对白底得 4.542,#777777 对白底得 4.478。后两个数字是这套标准的民间锚点——4.54 与 4.48,中间只差一个十六进制位,判定却相反。
顺带清掉一个长期被讨论的分歧:反伽马阈值到底该用 0.03928(WCAG 原文)还是 0.04045(sRGB 规范)。在 8 位色深下这两个值完全不可观测——256 个灰阶逐一对比,差异为 0;再取 20 万组随机前景/背景对,最大差异也是 0。原因是 10/255 = 0.03922 落在两个阈值之下,11/255 = 0.04314 落在两者之上,中间那段区间没有任何 8 位取值。为一个产生不了差异的常数争论,纯属浪费。
二、边界是一格十六进制,不是 0.001
灰阶扫描的结果很干脆:
#767676 对白底 4.5422 通过
#777777 对白底 4.4781 失败
相邻灰阶步长 0.0641
也就是说,设计稿上把 #767676 调成 #777777,肉眼完全看不出变化,校验结果直接翻转。3:1 的边界同样是"差一格":#949494 得 3.0335,#959595 得 2.9953。256 个灰阶里,能过 4.5 的只有 119 个,能过 3.0 的 149 个——过半的灰都过不了正文标准,这解释了为什么"看起来还行"的浅灰文字在实际阅读中总是吃力。
三、先四舍五入再比较,会放过 4.4989
校验器最容易写错的一行,是把结果舍入到两位再和阈值比:
# 错误写法
if round(contrast(fg, bg), 2) >= 4.5:
pass
实测抓到一个真实样本:#2da3fe 文字压在 #700229 背景上,对比度 4.4989,round(..., 2) 得到 4.50,判定"通过"。差了 0.0011,就是不合格。正确写法是拿原始值比较,且只在展示时格式化:
ratio = contrast(fg, bg) # 4.498945107923931
if ratio >= 4.5: # 不要在这里 round
print(f"{ratio:.2f}") # 展示时才保留两位
同一个坑还有反向版本:某些工具把 4.495~4.5 之间的值显示成 4.50 并标绿,用户于是把 4.50 写进设计规范,后面的所有计算都建立在一个不存在的数字上。
四、3:1 只给"大文本",18px/600 不算
WCAG 对大文本的定义是硬的:字号 ≥ 24px,或者字号 ≥ 18.66px(14pt)且字重 ≥ 700。两个条件是与的关系。
18px / 600 → 不算大文本,阈值 4.5
18px / 700 → 算大文本,阈值 3.0
24px / 400 → 算大文本,阈值 3.0
font-weight: 600 是 semibold,不是 bold,这一条在视觉上几乎无法分辨,却决定了阈值差 1.5 倍。另一个隐蔽点是 rem:1.5rem 只有在根字号为 16px 时才是 24px,若 html { font-size: 10px },1.5rem 等于 15px,立刻退回 4.5 的判据。
同一份样式表里有个正面例子:.kv .n 是 21px / 700,命中"大文本",按 3:1 判定,实际 4.877,余量 62%;如果误按 4.5 判,余量只剩 8%——阈值取错,风险判断跟着错。
五、半透明颜色:朴素算法高估 2.6 倍
rgba() 里的 alpha 不参与对比度公式,必须先与背景合成:
def composite(fg, bg, alpha):
return tuple(round(f * alpha + b * (1 - alpha)) for f, b in zip(fg, bg))
四组实测,朴素算法(直接用 fg)与真实合成值的差距:
| 前景 | 背景 | 朴素值 | 合成后 | 真实值 | 结论 |
|---|---|---|---|---|---|
rgba(0,0,0,.60) |
#ffffff |
21.00 | #666666 |
5.74 | 高估 265.7% |
rgba(0,0,0,.45) |
#ffffff |
21.00 | #8c8c8c |
3.36 | 高估 524.5%,实际不合格 |
rgba(255,255,255,.70) |
#0969da |
5.19 | #b5d2f4 |
3.34 | 高估 55.7%,实际不合格 |
rgba(31,35,40,.55) |
#ffffff |
15.80 | #848689 |
3.65 | 高估 332.7%,实际不合格 |
后三行都是"看起来通过、实际不通过"。黑色半透明尤其危险:任何 alpha 下朴素值都是 21.00,一个 .45 的次要文字会被判成最高对比度。
六、父级 opacity 会把整棵子树一起压暗
opacity 与 rgba 的差别是作用范围:它先把整个元素(含子元素、边框、阴影)画到离屏图层,再整体与背景合成。所以对文字的影响不是"变淡一点",而是重新算一遍对比度。以 #1f2328(原值 15.80)在白底上为例:
| 父级 opacity | 合成后文字色 | 对比度 |
|---|---|---|
| 0.9 | #35393d |
11.64 |
| 0.8 | #4c4f53 |
8.23 |
| 0.7 | #626569 |
5.86 |
| 0.6 | #797b7e |
4.24 |
| 0.5 | #8f9194 |
3.16 |
这条线的下限是 0.7。用 opacity: .6 做"禁用态或次要文字",15.80 会掉到 4.24,直接不合格;而禁用的控件在 WCAG 里是豁免项,不承载信息的次要文字却不是——同一个 .6,一处合规一处违规,取决于它压在什么内容上。
七、真正的坑:token 跨主题复用
同一套语义色,白底全部合格(5.08 到 5.36),搬到 #0d1117 深色底上:
#1f2328 → 1.20 #0969da → 3.64 #9a6700 → 3.89
#59636e → 3.10 #1a7f37 → 3.73 #cf222e → 3.53
#d8dee4 → 13.96 #f0f6fc → 17.39
六个语义色(正文、次要文字、链接、成功、警告、危险)在深色底上全部低于 4.5,最低的正文色只有 1.20——几乎是同色。这不是"深色模式没调好",而是浅色 token 直接复用必然的结果:深色底需要的是提亮后的同一色相,不是原来的十六进制值。
结论很直接:对比度校验必须按"主题 × 组件"的组合跑,不能按 token 单独跑。一个 token 在白底合格,不代表它在任何背景上都合格;反过来,一个 token 被判失败,也可能只是因为它被拿去配了不该配的背景。
八、1% 的余量等于没有余量
审计里最险的一组是评级徽章:白字压在 #6e7781 上,4.547,余量 1.0%。相邻一档 #6f7a84 立刻掉到 4.381:
#ffffff on #6e7781 → 4.547 通过(余量 1.0%)
#ffffff on #6f7a84 → 4.381 失败
#ffffff on #6d7780 → 4.565 通过
20 组文字配色全部过了 4.5,把阈值抬到 4.725(即 5% 余量)后只剩 1 组落榜,正是这个 4.547。所以把 CI 卡在 4.725 是低成本的:它只淘汰真正贴线的配色,却能挡住后续任何一次微调。
九、装饰性边框报红,不等于违规
24 组里有 2 组失败,都是同一个颜色:#d8dee4 在白底上的分隔线,1.356。但 WCAG 1.4.11(非文本对比)的适用范围是"可操作组件的视觉边界与有含义的图形",纯装饰性的分隔线不在其中。同一份样式表里真正需要看的是焦点环与表单边框:
#afb8c1 焦点环候选 → 2.01 失败(需 3.0)
#8c959f 焦点环候选 → 3.04 刚过
#d8dee4 表格/标题分隔线 → 1.36 装饰,不适用
这两个数字解释了为什么键盘 Tab 一圈常常"看不见焦点在哪":2.01 的环在浅灰背景上几乎等于背景。
十、公式描述的是纯色,不是屏幕上的字
最后一步实测最容易被忽略。用 Canvas 逐像素扫描渲染结果,把 #767676 在 16px / 400 下的墨迹像素按对比度排序:
公式值 4.54
达到该色的墨迹像素 19.0%
墨迹中位像素等效对比度 2.61
12px / 400 时中位只有 2.03;16px / 700 时达到该色的比例升到 33%,中位 3.11;24px / 400 时中位仍是 2.61。原因不神秘:字形边缘由抗锯齿像素填充,越细的笔画里"纯色像素"占比越低。
这不是 WCAG 的算法,也不能拿它去判违规;它只说明一件事——4.542 描述的是最深处的那个颜色,不是整行字给人的观感。所以 4.5 应当被当成及格线而不是目标值:这份样式表里 body 是 15.80、.sub 是 6.11,正文读起来就比 4.55 的徽章舒服得多。
写在最后
对比度校验的价值不在那个两位小数,而在三个动作:把 alpha 和 opacity 先合成再计算,把字号字重真正代入阈值判断,把 token 放进每一个实际背景里去跑。少做任何一步,绿勾都只是"这个颜色单独看没问题",和页面能不能读,关系不大。
- 点赞
- 收藏
- 关注作者
评论(0)