你为品牌色选了 #B45309。营销组喜欢、CEO 也喜欢,于是在 #FFF7ED 上铺了一遍 #B45309,发了 landing page。两周后无障碍审计报告到了:每段正文的 WCAG AA 都不通过。对比度算下来 3.86 : 1,阈值是 4.5 : 1。该怎么办——直接换成纯黑、把品牌色丢掉吗?

打开颜色对比度检测器 →

这篇指南讨论的就是「审计挂了」到「修复上线」之间这几小时。覆盖 WCAG 2.x 对比度公式、你真正要过的三档阈值、容易踩坑的失败模式,以及保留品牌色相、只挪明度刚好通过 AA 的具体算法。

WCAG 究竟在测什么

WCAG 2.x 的对比度是相对亮度(relative luminance)的比值,不是感知到的”明暗”。公式分三步:

  1. 对每个 sRGB 通道 c,做 gamma 解码:
    • c / 255 ≤ 0.03928 时,c_lin = (c / 255) / 12.92
    • 否则 c_lin = ((c / 255 + 0.055) / 1.055) ^ 2.4
  2. 把线性化后的通道加权合成单一亮度:
    • L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
  3. 较亮的放分子上取比值:
    • ratio = (L_max + 0.05) / (L_min + 0.05)

+ 0.05 是 flare 偏移项,防止某一方亮度趋近 0 时比值发散。结果落在 1(无对比)到 21(纯黑配纯白)之间。所有 AA/AAA 阈值——4.5、7、3——都只是对这一个数字做一刀切。

亮度系数 0.2126 / 0.7152 / 0.0722 来自 ITU-R BT.709 sRGB 规范,背后是物理事实:人眼对绿光最敏感、对蓝光最迟钝。同样 RGB 强度的纯红、纯绿、纯蓝在亮度上其实差很多,绿色承担了绝大部分被感知到的光。

你真正要过的三档阈值

WCAG 把文字与非文字拆成三档,每档各有 AA / AAA 目标:

元素AAAAA适用范围
正文(1.4.3)4.5 : 17 : 1小于 18 px 常规或 14 px 粗体
大号文字3 : 14.5 : 118.66 px 粗体及以上、或 24 px 常规及以上
UI 组件与图形(1.4.11)3 : 1边框、聚焦环、图标、图表线条

三个常被忽视的点:

  1. AAA 不只是「锦上添花」。WCAG 把 AAA 定位为延伸目标,但具体行业(美国 Section 508 联邦采购、部分欧盟公共机构)会要求产品某些部分达到 AAA。在和设计协商前先确认你的产品哪些场景背了这个义务。
  2. 大号文字阈值宽松到 3 : 1。这就是为什么营销 landing page 的巨型衬线大字可以用比正文低得多的低对比度强调色。
  3. UI 没有 AAA 阈值。WCAG 2.1 加了 1.4.11,定在 3 : 1 就停了。别去找一个不存在的”UI AAA”规则;如果内部要求更严,就自己定一套并写明依据。

容易踩坑的失败模式

下面这些都不是稀有 case。我见过的每家公司都至少有一次踩到过其中之一并把 bug 上线。

“AA 过了就发车”——但审计工具用的不是同一把尺

WCAG 2.x 不是唯一的对比度模型。仍处于草案的 APCA(Advanced Perceptual Contrast Algorithm)用感知亮度对比,刻度叫 Lc,有些较新的审计工具会同时报这两个数。APCA 是写给 WCAG 3 用的,目前还在 draft 状态。如果你按 WCAG 2.x 上线,审计工具又在报 APCA,数字对不上是正常的——这是两把尺。

现阶段以 WCAG 2.x 为准上线,因为这是 Section 508 / EN 301 549 / EAA 等现行法律所引用的标准。APCA 当作研究信号在内部跟踪即可。

半透明文字

对比度公式只对不透明色生效。如果你的文字是 rgba(20, 20, 20, 0.6) 落在带纹理的背景上,真正渲染出来的颜色取决于下层是什么。先把前景叠加到你预期的真实背景上,再对叠加结果跑对比度公式。多数团队跳过这一步,于是发出去的对比度 bug 只在照片或渐变背景上才暴露。

中灰背景让”调暗前景”失效

当背景亮度大约在 0.18 一带(接近 #888)时,纯黑或纯白都到不了 4.5 : 1。黑色顶到约 4.0 : 1,白色顶到约 4.4 : 1。这种情况下要换的是背景,不是前景。设计师会习惯性地”再把字调深一点”,但在中灰背景上这个动作就是不管用。

Placeholder 文字也算

绝大多数浏览器会给 ::placeholder 默认上 50% 不透明度。意味着 #1f2937 的 placeholder 落在 #f9fafb 背景上,真实呈现接近 #88888d,AA 过不了。WCAG 把 placeholder 视作文字。你要么把 placeholder 调亮,要么接受这个 input 在用户开始输入前是无障碍不达标的。

表单标签和 disabled 状态

WCAG 2.x 对 disabled 控件本身豁免对比度要求,但仅限控件本身。disabled 控件旁边的标签不豁免。审计工具经常分不清这点,人工审核不能错过。

「保留色相、调整明度」是怎么实现的

#B45309#FFF7ED 上挂 AA(3.86 : 1)时,最容易想到的修复是”换成黑”。但品牌色被选定就是为了它的色相身份,换黑等于把品牌扔掉。正确的动作:色相和饱和度保持不变,只挪感知明度,直到通过对比阈值。

这件事用 OKLCH(极坐标形式的 Oklab)做最干净,原因是它的 L 轴是感知均匀的——从 L = 0.6 走到 L = 0.5 看上去和 L = 0.4 走到 L = 0.3 是同一个量级的变化。sRGB 的明度没有这个性质,等距 sRGB 步长在暗端看上去差异比亮端大得多。

算法:

function fix(fg, bg, target = 4.5) {
  const fgLch = rgbToOklch(fg);          // 保留 hue h 与 chroma C
  // 两个方向都试:把 L 调暗 vs 调亮
  let best = null;
  for (const dir of [-1, 1]) {
    let lo = fgLch.L;
    let hi = dir < 0 ? 0 : 1;
    // 二分:找从起点出发、最小 |delta L| 通过阈值的那个 L
    for (let i = 0; i < 22; i++) {
      const mid = (lo + hi) / 2;
      const trial = clampToSrgb(oklchToRgb({ ...fgLch, L: mid }));
      if (contrast(trial, bg) >= target) hi = mid;
      else lo = mid;
    }
    const final = clampToSrgb(oklchToRgb({ ...fgLch, L: hi }));
    const r = contrast(final, bg);
    if (r >= target * 0.99) {
      const delta = Math.abs(hi - fgLch.L);
      if (!best || delta < best.delta) best = { rgb: final, ratio: r, delta };
    }
  }
  return best ?? blackOrWhiteFallback(bg);
}

两点说明:

  • target * 0.99 的容差用于消化 OKLCH ↔ sRGB 矩阵的浮点误差。没有这个 slack,数学上等于 4.5 : 1 的结果可能读到 4.4998 : 1 而被否掉。
  • 回退到纯黑或纯白是色相在 sRGB 色域内打不到目标时的诚实答案。霓虹黄落在黄底上无论怎么调明度都不可能过 AA;算法应该承认这一点,而不是骗自己。

#B45309(品牌橙)放在 #FFF7ED(暖米色)上跑这个算法,落点大约在 #925108——同一色相,再深一点点,比值从 3.86 : 1 拉到 4.50 : 1。品牌识别保留,审计也通过。

各技术栈的代码示例

Python(仅 Pillow + 手写亮度)

def srgb_to_lin(c):
    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 = (srgb_to_lin(c) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast(c1, c2):
    L1, L2 = sorted([luminance(c1), luminance(c2)], reverse=True)
    return (L1 + 0.05) / (L2 + 0.05)

# (180, 83, 9) on (255, 247, 237)
print(round(contrast((180, 83, 9), (255, 247, 237)), 2))  # 3.86

无外部依赖。Pillow 只在你需要从图片里读像素颜色时才用到。已知前景/背景色对,公式十五行就够。

JavaScript(浏览器,零库)

const lin = c => (c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const lum = ({ r, g, b }) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const ratio = (a, b) => {
  const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};

核心就这么多。OKLCH 转换再加四十行也都能塞进一个文件。如果你只为一个工具用,不必引 chroma.jsculori——库是给你需要十种颜色操作时用的,不是给你只需要一种的时候用的。

Bash(CI 关卡)

# 任何 token 在 tokens.json 里对页面背景的对比度低于 4.5:1 就让 CI 挂掉
node -e '
  const tokens = require("./tokens.json");
  const bg = tokens["color.background.default"];
  let failed = 0;
  for (const [k, fg] of Object.entries(tokens)) {
    if (!k.startsWith("color.text.")) continue;
    const r = wcagContrast(fg, bg);
    if (r < 4.5) {
      console.error(`FAIL ${k} ${fg} on ${bg} = ${r.toFixed(2)}`);
      failed++;
    }
  }
  process.exit(failed ? 1 : 0);
'

这才是能撑下来的无障碍测试方式。手工审计会漂;一个能读 design token、把 PR 直接 fail 掉的 CI 关卡,才能在工程师打开评审之前就拦住回退。

这个工具和别家有什么不一样

WebAIM 的对比度检测器是事实标准,我们也没想替代它。值得记住的差异:

  • 保留色相的修正:WebAIM 只告诉你”挂了”。浏览器 DevTools 的取色器会告诉你”拖滑块去修”。两个都不会自动沿 OKLCH 明度轴走。这个工具会,并报告通过 AA 所需的最小明度变动。
  • 三档分别报告:正文、大号文字、UI 组件各有不同阈值。多数检测器只给一个 headline 数字,这个工具拆成三档,省得你在脑子里重新算阈值。
  • 四语言:跟非英文的设计团队协作、或在内部下发 a11y 规范时有用。
  • 不跟踪、不上传:每一次计算都在本地。你输入的 hex 不会发到任何服务器。还在 NDA 内的品牌色,这点很重要。

中文世界相关延伸

  • 国内常见 a11y 审计工具(如阿里飞猪 Aone、腾讯 ISUX、字节 ArcoDesign 的内置 audit)都按 WCAG 2.1 4.5 : 1 阈值跑
  • 中文字体的「大号文字」边界:CJK 字形在 18.66 px 粗体下笔画粘连比拉丁字形更严重,建议把大号文字的 实测起点 抬到 22 px 粗体或 28 px 常规
  • placeholder 在简体中文输入场景里出现频率高于英文产品(搜索、表单、聊天),placeholder 对比度更应该当成正文要求来过

拓展阅读

ZeroTool 站内相关工具

  • 配色方案生成器 — 给一个基色,生成 4 种和谐配色;做对比度检测之前用。
  • 颜色转换器 — HEX、RGB、HSL 互转,设计稿和代码之间倒值时用。
  • 色阶生成器 — Tailwind 风格的 10 档色阶,需要一整套 token 而不是单个颜色时用。