从 WCAG 到 APCA,从色相感知到描边补偿——一条可量化、可落地、可自动化的文字可读性工程主线
摘要
“深底白字、浅底深字”几乎是每个设计师入行第一天就听到的口诀,但真正把它落到工程里,却远比口诀复杂:同样是白字,放在 #7c3aed 上合格,放在 #fbbf24 上就是灾难;同样是黑字,放在 #f9fafb 上舒适,放在 #6b7280 上就糊成一团。本文以“对比度铁律”为贯穿全文的主线,从 WCAG 2.x 相对亮度公式、APCA 感知对比度模型,到描边补偿的数学边界、色觉缺陷(CVD)仿真,再到设计令牌、自动化校验脚本与 CI 卡点,构建一条从理论到落地的完整路径。文中给出可复用的对比度计算代码、描边宽度经验公式、以及一套面向组件库的“文字-背景-描边”三元组校验方案,并讨论未来在可变字体、动态主题、HDR 显示下的可读性挑战。
目录
一、口诀的边界:为什么“白字黑描边”不是万能药
“深底白字、浅底深字”这条口诀之所以流行,是因为它抓住了对比度最核心的变量——前景与背景的亮度差。但它只说了“方向”,没说“幅度”,更没说“边界”。在实际项目中,我们经常遇到三类翻车场景:一是背景亮度处在“中间地带”(比如中灰、莫兰迪色、低饱和粉紫),白字和黑字都不够;二是背景是高饱和暖色(黄、橙、青柠),白字亮度差不足,黑字又显得脏;三是背景是渐变或图片,局部区域对比度骤降。
“白字必配黑描边”正是针对第一、二类场景的补丁。描边的本质,是在文字与背景之间人为插入一层高对比度的“中介层”,让文字的感知边界不再依赖背景本身。但描边不是免费的:它会改变字形的视觉重量(optical weight),压缩字怀(counter),在小字号下导致笔画粘连,在大字号下又显得笨重。本文评述:描边是一种“对比度借贷”,你借来了边界清晰度,付出的代价是字形保真度和排版节奏。
笔者认为,把“白字黑描边”当成一条无条件规则,是把一个二维优化问题(前景色 × 描边)降维成了一维口号。真正可落地的做法,是建立“背景亮度 → 前景策略 → 描边参数”的决策树,并用可量化的阈值来切分。
为了把这条主线讲清楚,本文先补齐对比度的数学基础,再讨论 WCAG 与 APCA 两套模型的差异,然后分别拆解深底白字、浅底深字的失效机制与补偿手段,最后给出工程化的校验路径。所有阈值和公式都标注来源,模拟数据会明确标注“模拟”。
二、对比度的数学基础:从相对亮度到 WCAG 阈值
2.1 相对亮度公式
WCAG 2.x 的对比度定义建立在 sRGB 色彩空间的相对亮度(relative luminance)之上。其核心步骤是:先把每个通道的 8bit 值归一化到 [0,1],再做 gamma 反变换,最后按 0.2126R + 0.7152G + 0.0722B 加权求和。公式如下(来源:W3C WCAG 2.2,Understanding SC 1.4.3):
def srgb_to_linear(c):
c = c / 255.0
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def relative_luminance(r, g, b):
R = srgb_to_linear(r)
G = srgb_to_linear(g)
B = srgb_to_linear(b)
return 0.2126 * R + 0.7152 * G + 0.0722 * B
def contrast_ratio(fg, bg):
L1 = relative_luminance(*fg)
L2 = relative_luminance(*bg)
lighter, darker = max(L1, L2), min(L1, L2)
return (lighter + 0.05) / (darker + 0.05)
这个公式的关键在于那个 +0.05 的偏移项。它模拟了环境光在屏幕上的漫反射,使得纯黑与纯白的对比度上限被限制在 21:1,而不是理论上的无穷大。本文评述:这个 0.05 是整条铁律的“地基常数”,很多工程师在自研对比度函数时漏掉它,导致计算结果与浏览器 DevTools 不一致。
2.2 WCAG 阈值与字号分级
WCAG 2.2 对正文文字(normal text)要求 4.5:1,对大号文字(large text,定义为 18pt 或 14pt 加粗以上)要求 3:1。非文本对比度(SC 1.4.11)要求 UI 组件与图形对象达到 3:1。这些阈值不是拍脑袋定的,而是源自 1990 年代对低视力用户阅读速度的实证研究(Legge 等,1990;Arditi,2005)。
需要强调的是,WCAG 2.x 的对比度是线性亮度比,它与人眼感知并非线性关系。这就引出了后文 APCA 的登场。但在工程实践中,WCAG 2.x 仍然是法律合规(如美国 ADA、欧盟 EN 301 549)的主要依据,短期内无法完全抛弃。
2.3 用代码验证一条真实配色
以本文主题色 #7c3aed(紫)配白字为例,代入公式:R=124, G=58, B=237。归一化后做 gamma 反变换,得到相对亮度约 0.1287。白字亮度为 1.0。对比度 = (1.0+0.05)/(0.1287+0.05) ≈ 5.87:1,满足 AA 正文要求。若换成 #a78bfa(浅紫),相对亮度约 0.3612,对比度 = 1.05/0.4112 ≈ 2.55:1,连大号文字的 3:1 都不到。这就是为什么浅紫底上放白字必须加描边或换深字。
以下是一个可直接在浏览器控制台运行的校验片段,方便读者随手验证:
// 在 DevTools Console 中运行
function lum(hex){
const n = parseInt(hex.slice(1),16);
const rgb = [(n>>16)&255,(n>>8)&255,n&255].map(v=>{
v/=255; return v<=0.04045? v/12.92 : Math.pow((v+0.055)/1.055,2.4);
});
return 0.2126*rgb[0]+0.7152*rgb[1]+0.0722*rgb[2];
}
function ratio(a,b){
const L1=lum(a),L2=lum(b);
const hi=Math.max(L1,L2),lo=Math.min(L1,L2);
return ((hi+0.05)/(lo+0.05)).toFixed(2);
}
console.log(ratio('#ffffff','#7c3aed')); // "5.87"
console.log(ratio('#ffffff','#a78bfa')); // "2.55"
三、APCA 与 WCAG 3 的范式转移
3.1 为什么 WCAG 2.x 不够用
WCAG 2.x 的对比度公式有一个著名缺陷:它对深色文字在浅色背景上的表现估计偏乐观,对浅色文字在深色背景上的表现估计偏保守。换句话说,同样算出 4.5:1,白底黑字读起来比黑底白字更轻松,但公式认为两者等价。这一现象在 2019 年前后被 Andrew Somers 等人系统性地指出,并催生了 APCA(Accessible Perceptual Contrast Algorithm)。
APCA 的核心改进有三点:第一,使用更接近人眼响应的幂函数,而非简单线性比;第二,区分“正极性”(深字浅底)和“负极性”(浅字深底),对两者采用不同的指数;第三,把字号和字重纳入计算,输出一个带符号的 Lc 值(Lightness Contrast),而不是一个无方向的比值。本文评述:APCA 把“对比度”从一个纯色彩问题,升级成了“色彩 × 排版”的联合问题,这是它比 WCAG 2.x 更贴近真实阅读体验的根本原因。
3.2 APCA 的 Lc 阈值参考
APCA 目前给出的参考阈值(来源:APCA 0.1.9 文档,2023)大致如下:正文文字建议 |Lc| ≥ 75,大号文字 ≥ 60,非文本元素 ≥ 45,占位符/禁用态 ≥ 30。注意 Lc 是带符号的,负值表示浅字深底。以下表格为模拟整合数据,用于说明不同组合的 Lc 量级差异,实际数值请以官方计算器为准。
从表中可以看到,WCAG 认为 #fff/#a78bfa 的 2.55:1 与 #1e1b2e/#fbbf24 的 9.1:1 差距巨大,而 APCA 的 Lc 差距(52 vs 82)相对温和。这提示我们:低对比度场景的“补救空间”比 WCAG 比值暗示的更大,描边、字重、字号都能有效补偿。
3.3 两套模型并用的工程策略
笔者认为,短期内最务实的策略是“双轨制”:合规层面继续以 WCAG 2.2 为硬门槛(因为它是法律依据),体验层面用 APCA 做二次筛选,把 WCAG 达标但 APCA 偏低的组合标记为“建议优化”。具体操作路径:
- 在令牌层为每个文字/背景组合同时记录 WCAG 比值与 APCA Lc;
- WCAG < 4.5 且 APCA |Lc| < 60 的组合直接拦截;
- WCAG ≥ 4.5 但 APCA |Lc| < 75 的组合进入“观察名单”,在 CI 中输出警告而非报错;
- 对观察名单中的组合,优先通过调整背景明度(而非加描边)解决。
四、深底白字的失效场景与描边补偿模型
4.1 深底白字什么时候会失效
深底白字失效,通常不是因为背景“不够深”,而是因为背景的亮度分布不均匀或局部亮度抬升。典型场景包括:
- 渐变背景:从 #4c1d95 过渡到 #a78bfa,右半部分白字对比度骤降;
- 图片背景:照片中的高光区域(天空、灯光、白色衣物)让白字“融化”;
- 高饱和暖色:如 #f59e0b、#fbbf24,其相对亮度可达 0.5 以上,白字对比度不足 2:1;
- 半透明遮罩:rgba(0,0,0,0.4) 叠在浅色图上,实际背景亮度仍偏高。
这些场景的共同点是:背景亮度在空间上不可控。此时描边(或文字阴影、背景模糊、局部暗化)就成了必要的“局部对比度修复”。
4.2 描边的数学边界:多宽才够
描边的有效性取决于三个变量:描边宽度 w、描边与背景的对比度 C1、文字与描边的对比度 C2。一个常被忽略的事实是:描边本身也会“吃掉”字形的有效面积。当 w 超过字干宽度(stem width)的约 1/4 时,字怀开始闭合,可读性反而下降。
基于对多款无衬线字体(Inter、Roboto、Source Han Sans)的观察,本文给出一个经验公式(模拟推导,供工程参考):
推荐描边宽度 w ≈ k × font-size / 100
其中 k 取值:
小字号(< 16px):k = 0.8 ~ 1.0
中字号(16~24px):k = 0.6 ~ 0.8
大字号(> 24px):k = 0.4 ~ 0.6
同时满足约束:w ≤ stem_width / 4
以 Inter Regular 16px 为例,stem 约 1.6px,则 w ≤ 0.4px
此时更推荐用 text-shadow 做“柔边”而非硬描边
本文评述:硬描边(-webkit-text-stroke)在小字号下几乎必然损伤字形,工程上更稳妥的做法是用多层 text-shadow 模拟柔边,既提升边界对比度,又保留字形细节。下面是一个可直接使用的 CSS 片段:
.text-on-image {
color: #ffffff;
/* 四向柔边 + 轻微下沉,模拟描边 */
text-shadow:
0 1px 2px rgba(0,0,0,0.85),
0 0 1px rgba(0,0,0,0.9),
0 0 6px rgba(0,0,0,0.45);
font-weight: 600; /* 适度加粗,补偿柔边带来的视觉变细 */
}
.text-on-image--hard {
color: #ffffff;
-webkit-text-stroke: 0.5px rgba(0,0,0,0.9);
paint-order: stroke fill; /* 关键:让描边在填充之下 */
}
paint-order: stroke fill 是一个容易被忽略但极其关键的属性。默认情况下,描边会覆盖在填充之上,导致字形变细、笔画粘连;设置 paint-order 后,描边绘制在填充下方,字形轮廓得以保留。本文评述:这一条属性应该成为所有“白字黑描边”场景的默认配置。
4.3 替代方案对比
笔者认为,方案选择的优先级应该是:换背景 > 局部暗化 > 柔边阴影 > 硬描边。描边是最后手段,而不是第一反应。
五、浅底深字的陷阱:亮度差、色相差与字重
5.1 浅底深字并非“天然安全”
很多人以为浅底深字只要“够深”就行,但实际翻车案例并不少。典型问题有三类:一是深色文字饱和度太高,与浅色背景产生“色振动”(chromatic vibration),边缘发虚;二是背景虽浅但带有明显色相,深色文字与之形成低色相差,边界模糊;三是字重过细,在浅底上“发灰”。
一个经典反例是 #6b7280(中灰)文字放在 #f9fafb(近白)背景上。WCAG 比值约 4.6:1,勉强达标,但实际阅读时,由于两者亮度差不够大,文字显得“浮”在背景上,长时间阅读容易疲劳。本文评述:WCAG 达标 ≠ 阅读舒适,这是两套模型都需要正视的“灰色地带”。
5.2 亮度差与色相差的联合优化
在浅底深字的场景中,提升可读性有两条路径:拉大亮度差,或拉大色相差。前者更直接,后者更“高级”。以 #faf7ff(浅紫底)为例,如果文字用 #4c1d95(深紫),亮度差足够,但色相差小,边界偏柔;如果改用 #1e1b2e(近黑紫),亮度差更大,边界更锐利,但会损失一点品牌调性。
工程上可以用一个简单的“双阈值”策略:亮度差(ΔL)优先满足,色相差(ΔH)作为辅助。具体来说,当 ΔL 对应的 WCAG 比值 ≥ 7:1 时,色相差可以放宽;当比值在 4.5~7:1 之间时,建议色相差 ≥ 30°(HSL 色相环),以补偿亮度差的不足。
5.3 字重与字号的补偿作用
字重和字号是“隐形的对比度”。同样一条 #6b7280 文字,Regular 300 在 14px 下几乎不可读,换成 Medium 500 在 16px 下就明显改善。这是因为笔画变粗后,单位面积内的墨量增加,人眼对边界的检测更稳定。本文评述:在对比度临界区,把字重从 400 提到 500 的收益,往往大于把颜色从 #6b7280 调到 #4b5563,而且对品牌色的破坏更小。
一个可操作的规则:当 WCAG 比值在 4.5~6:1 之间时,正文最小字重不低于 500,最小字号不低于 15px;当比值 ≥ 7:1 时,可以放宽到 400 字重、14px 字号。
六、色觉缺陷视角下的对比度重估
6.1 三类 CVD 的感知差异
色觉缺陷(Color Vision Deficiency)主要分为红色盲/红色弱(protan)、绿色盲/绿色弱(deutan)和蓝色盲/蓝色弱(tritan)。其中 protan 和 deutan 合计占男性人口的约 8%(来源:Birch,2012;Colour Blind Awareness 统计),是最需要关注的群体。
对这两类人群而言,红绿方向的色相差几乎不可用,他们主要依赖亮度差来区分文字与背景。这意味着:一个在正常色觉下“色相差很大、亮度差一般”的配色,对 CVD 用户可能完全失效。本文评述:WCAG 的亮度公式恰好对 CVD 友好,因为它只看亮度,不看色相。这也是为什么在 CVD 场景下,WCAG 比值比色相差更值得信赖。
6.2 用仿真工具做“CVD 压力测试”
工程上可以用 Brettel 1997 或 Machado 2009 的 CVD 仿真矩阵,把设计稿转换到 protan/deutan/tritan 三种视角,再重新计算对比度。以下是一个基于 Machado 矩阵的简化实现(模拟数据,用于演示流程):
# Machado 2009 deutan 100% 近似矩阵
DEUTAN = [
[0.367322, 0.860646, -0.227968],
[0.280085, 0.672501, 0.047413],
[-0.011820, 0.042940, 0.968881],
]
def simulate_cvd(rgb, matrix):
r, g, b = rgb
return tuple(
max(0, min(255, int(round(
matrix[i][0]*r + matrix[i][1]*g + matrix[i][2]*b
))))
for i in range(3)
)
# 对前景和背景分别仿真后,再算对比度
fg_sim = simulate_cvd((255,255,255), DEUTAN)
bg_sim = simulate_cvd((124,58,237), DEUTAN)
print(contrast_ratio(fg_sim, bg_sim))
实操建议:把 CVD 仿真纳入设计评审流程,对每个关键配色输出“正常 / protan / deutan / tritan”四组对比度,任何一组低于 4.5:1 的组合都需要重新评估。Chrome DevTools 的 Rendering 面板已内置 Emulate vision deficiencies 功能,可以快速做视觉检查。
6.3 描边在 CVD 场景下的特殊价值
由于描边通常是黑或白这类无彩色,它对 CVD 用户是“安全”的。这意味着在色相不可靠的场景下,描边提供了一条额外的、不依赖色觉的边界线索。本文评述:这也是“白字必配黑描边”这条口诀在无障碍领域长期成立的原因之一——它本质上是在用亮度通道兜底色相通道。
七、工程落地:设计令牌、校验脚本与 CI 卡点
7.1 设计令牌的三元组结构
要让对比度铁律真正落地,令牌层不能只存“文字色”和“背景色”,而要存“文字-背景-描边”三元组。推荐结构如下(JSON 示例):
{
"text.onPrimary": {
"foreground": "#ffffff",
"background": "#7c3aed",
"stroke": null,
"wcag": 5.87,
"apca": -78,
"minSize": 14,
"minWeight": 400
},
"text.onPrimarySoft": {
"foreground": "#ffffff",
"background": "#a78bfa",
"stroke": "rgba(0,0,0,0.85)",
"strokeWidth": "0.5px",
"wcag": 2.55,
"apca": -52,
"minSize": 18,
"minWeight": 600
}
}
这样,组件在使用令牌时,可以自动带上最小字号和字重约束,避免“令牌对了但用法错了”的情况。
7.2 自动化校验脚本
下面是一个可集成到构建流程的 Node 脚本,遍历令牌文件,输出违规项。它同时计算 WCAG 和 APCA(APCA 采用 0.1.9 的简化实现,实际项目建议引入官方 npm 包 apca-w3):
const tokens = require('./tokens.json');
function check(tokens) {
const errors = [];
for (const [name, t] of Object.entries(tokens)) {
const ratio = contrastRatio(t.foreground, t.background);
if (ratio < 4.5 && !t.stroke) {
errors.push(`${name}: WCAG ${ratio}, 无描边补偿`);
}
if (ratio < 3 && t.stroke) {
errors.push(`${name}: WCAG ${ratio}, 描边不足以兜底`);
}
}
return errors;
}
const errs = check(tokens);
if (errs.length) {
console.error('对比度校验失败:\n' + errs.join('\n'));
process.exit(1);
}
在 CI 中,这个脚本应该作为独立 job 运行,失败即阻断合并。对于存量项目,可以先设为 warning,逐步收敛。
7.3 运行时兜底:动态背景的对比度守卫
对于图片背景、用户自定义主题等运行时才确定颜色的场景,需要在客户端做动态计算。一个可行方案是:对背景图做局部采样,计算文字所在区域的平均亮度,再动态切换文字色或描边强度。以下是一个简化的采样思路:
function pickTextColor(bgImage, region) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
const data = ctx.getImageData(region.x, region.y, region.w, region.h).data;
let sum = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
sum += relativeLuminance(data[i], data[i+1], data[i+2]);
count++;
}
const avg = sum / count;
return avg > 0.5 ? '#1e1b2e' : '#ffffff';
}
本文评述:运行时兜底是“最后一道防线”,它不能替代设计阶段的静态校验,但能覆盖大量不可预知的真实内容。两者结合,才能形成完整的可读性保障体系。
八、前沿预判:可变字体、动态主题与 HDR
8.1 可变字体带来的“对比度自适应”
可变字体(Variable Fonts)允许在运行时连续调整字重、字宽甚至光学尺寸(optical size)。这为对比度自适应提供了新可能:当检测到背景对比度偏低时,自动提升字重或切换到光学尺寸更大的字形,而不是简单地加描边。本文评述:这可能是未来五年内最值得关注的“排版级无障碍”方向。
8.2 动态主题与系统级暗色模式
随着 prefers-color-scheme 和 color-scheme 的普及,同一套令牌需要在明暗两套主题下都达标。这要求令牌设计从“单值”转向“对偶值”,并在两套主题下分别校验。一个常见的坑是:品牌色在浅色主题下配白字达标,在深色主题下配白字却不达标(因为深色主题的背景往往不是纯黑,而是深灰)。
8.3 HDR 与广色域显示的挑战
HDR 显示器和 Display P3 广色域正在普及,这意味着同一个 sRGB 颜色在不同设备上的实际亮度可能不同。WCAG 2.x 的公式基于 sRGB,在 P3 色域下会失真。目前 W3C 的 CSS Color 4 规范已经引入 color() 函数和 lab()/lch() 色彩空间,未来对比度计算可能需要迁移到感知均匀的色彩空间。本文评述:这是对比度铁律在下一个十年的最大变量,值得持续跟踪。
九、结论与操作清单
回到文章开头的主线:“深底白字、浅底深字”是一条方向正确的口诀,但它需要被拆解成可量化、可执行、可校验的工程步骤。以下是本文给出的操作清单:
- 所有文字/背景组合同时计算 WCAG 比值与 APCA Lc,双轨校验;
- WCAG < 4.5 且无描边补偿的组合,直接拦截;
- 描边优先用多层 text-shadow,硬描边仅用于大字号,并设置 paint-order: stroke fill;
- 浅底深字在 4.5~7:1 区间时,字重不低于 500,字号不低于 15px;
- 关键配色做 protan/deutan/tritan 三视角仿真,任一视角低于 4.5:1 即重评;
- 令牌层存储“文字-背景-描边”三元组,附带最小字号/字重约束;
- CI 中集成对比度校验脚本,失败阻断合并;
- 图片/动态背景场景,运行时做局部亮度采样与文字色切换。
对比度不是一道“及格线”,而是一个连续的、多维的优化空间。把这条铁律从口号变成流水线,才是真正对读者眼睛负责的做法。
主要参考文献
[1] W3C. Web Content Accessibility Guidelines (WCAG) 2.2. W3C Recommendation, 2023.
[2] Somers A. APCA (Accessible Perceptual Contrast Algorithm) 0.1.9 Documentation. 2023.
[3] Machado G M, Oliveira M M, Fernandes L A F. A Physiologically-based Model for Simulation of Color Vision Deficiency. IEEE TVCG, 2009.
[4] Legge G E, Parish D H, Luebker A, et al. Psychophysics of Reading: V. The Role of Contrast in Normal Vision. Vision Research, 1990.
[5] Arditi A. Enhancing the Accessibility of Text: Contrast and Legibility. Journal of Visual Impairment & Blindness, 2005.
[6] Birch J. Worldwide Prevalence of Red-Green Color Deficiency. JOSA A, 2012.
[7] W3C. CSS Color Module Level 4. W3C Working Draft, 2024.
[8] Brettel H, Viénot F, Mollon J D. Computerized Simulation of Color Appearance for Dichromats. JOSA A, 1997.
[9] 中国信息通信研究院. 移动应用无障碍设计指南(2023版). 2023.
文章声明:本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。
拓展阅读:WCAG 2.2 Quick Reference | APCA 官方仓库 | Chrome DevTools 无障碍参考

