为什么 HSL 的明度会欺骗你的眼睛 — OKLCH 解决了什么
HSL 把第三个轴叫做"lightness(明度)",因此看起来像是感知均匀的,但它本质上只是把经过 gamma 编码的 sRGB 坐标做了一次圆柱形重映射。L 值就是三个 RGB 通道的 (max + min) / 2,完全没有考虑人眼对不同波长的亮度感知差异。经典示例:hsl(60, 100%, 50%) 是纯黄色,hsl(240, 100%, 50%) 是纯蓝色。两者的 L 都是 50%,但相对亮度(relative luminance)分别是 0.928 和 0.072 — 黄色在人眼里要亮 12 倍以上。
一旦你固定 HSL 明度、只旋转 hue 来生成调色板,问题就会爆发:黄色和青色的色阶亮得刺眼,蓝色和紫色的色阶却几乎发黑;在一个色块上通过对比度检查的白色文字,换到相邻色块就不达标了。团队最后只能逐个手工微调,系统化调色板的初衷也就荡然无存。
现代的解法是 OKLCH,它在 CSS Color Module Level 4 中被标准化,自 2023 年起所有主流浏览器都已支持(Chrome 111、Safari 15.4、Firefox 113)。OKLCH 的 L 轴是感知均匀的:oklch(70% 0.1 60) 和 oklch(70% 0.1 240) 看起来确实一样亮。固定 L 和 chroma、只旋转 hue,就能得到与白色或黑色的对比度保持一致的色阶。一次性的快速微调用 HSL 即可;一旦调色板需要系统化,就该换 OKLCH。
/* Same HSL "lightness", wildly different brightness */
.hsl-yellow { background: hsl(60 100% 50%); } /* luminance 0.928 */
.hsl-blue { background: hsl(240 100% 50%); } /* luminance 0.072 */
/* OKLCH: the L axis is perceptual, so these really match */
.ok-yellow { background: oklch(70% 0.15 100); }
.ok-blue { background: oklch(70% 0.15 260); }
/* Hue-rotated ramp with constant perceived lightness */
:root {
--cat-1: oklch(70% 0.12 30);
--cat-2: oklch(70% 0.12 150);
--cat-3: oklch(70% 0.12 270);
}你可以亲手算出来的 WCAG 对比度数学
WCAG 对比度不是黑盒,而是十行代码就能实现的公式。首先把每个 sRGB 通道线性化:先除以 255,如果结果小于等于 0.04045 就再除以 12.92,否则计算 ((c + 0.055) / 1.055) 的 2.4 次方。相对亮度(relative luminance)是加权和 0.2126 R + 0.7152 G + 0.0722 B。权重体现了各原色对感知亮度的贡献,这也是绿色占主导、蓝色几乎不起作用的原因。两个颜色之间的对比度是 (L1 + 0.05) / (L2 + 0.05),其中较亮者作为 L1,取值范围从 1:1 到 21:1(纯白上的纯黑)。
值得背下来的阈值:普通正文文字 AA 级要求 4.5:1;大号文字(约 24px,或 18.66px 加粗)以及 — 自 WCAG 2.1 SC 1.4.11 起 — 输入框边框、焦点环、图标等非文字 UI 要求 3:1;AAA 级要求 7:1。一个有用的校准点:白底上的 #767676 恰好是 4.54:1,是能通过正文 AA 的最浅灰色。随处可见的 #999999 只有 2.85:1,不合格;实际无障碍审计中最常见的问题就是 placeholder 和禁用状态标签用的灰色。
一个警告:WCAG 2.x 公式已知会高估深色背景上浅色文字的可读性,数值上通过 4.5:1 的暗色模式文字,实际阅读体验可能不如亮色模式下的对应配色。在暗色主题里,把 4.5:1 当作下限,而不是目标。
function luminance(r, g, b) {
const lin = (c) => {
c /= 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrast(rgb1, rgb2) {
const L1 = luminance(...rgb1);
const L2 = luminance(...rgb2);
const [hi, lo] = L1 > L2 ? [L1, L2] : [L2, L1];
return (hi + 0.05) / (lo + 0.05);
}
contrast([118, 118, 118], [255, 255, 255]); // 4.54 — #767676 barely passes AA
contrast([153, 153, 153], [255, 255, 255]); // 2.85 — #999999 failsHEX 写法的边角案例:3、4、6、8 位
CSS 接受四种长度的 hex:#RGB、#RGBA、#RRGGBB、#RRGGBBAA。缩写形式的展开方式是每一位数字重复一次,而不是补零:#f0c 展开为 #ff00cc,#f0c8 展开为 #ff00cc88。手动缩写时这个区别很关键 — #abc 是 #aabbcc,绝不是 #0a0b0c。
8 位形式把 alpha 放在末尾。Android(以及旧版 .NET/WPF)使用 alpha 在前的 ARGB,即 #AARRGGBB。于是同一个字符串 #80FF0000 在 Android 上是 50% 不透明度的纯红,在 CSS 里却被解析为 red 0x80、green 0xFF、blue 0x00、alpha 0x00 — 一个完全透明的黄绿色。页面上什么都不显示,也不报任何错,这个 bug 通常要等到有人逐字符 diff 两套代码库才会被发现。在 Android 应用和 Web CSS 之间移植颜色时,务必重排 alpha 字节的位置。
alpha 百分比和 hex 的映射也并不干净。50% 不透明度是 0.5 × 255 = 127.5,四舍五入为 128 = 0x80 — 所以半透明是 #...80,而不是很多人凭直觉写的 #...7F。hex 的 alpha 只有 255 个离散档位,而 rgb(r g b / 50%) 能保留精确比例,这正是设计 token 越来越偏向斜杠语法的原因。
最后,hex 是仅限 sRGB 的写法。不存在广色域 hex:Display-P3 颜色根本没有 hex 拼法,所以任何把 P3 值导出成 hex 的工具,实际上都先悄悄做了一次向 sRGB 的 gamut 映射。
#f0c -> #ff00cc (digit doubling, not zero padding)
#f0c8 -> #ff00cc88 (4-digit form is RGBA)
50% alpha: 0.5 x 255 = 127.5 -> rounds to 0x80
CSS 50% red: #FF000080 (RRGGBBAA, alpha last)
Android 50% red: #80FF0000 (AARRGGBB, alpha first)
/* Same string, different platform, silent bug: */
CSS #80FF0000 = rgba(128, 255, 0, 0) -> invisiblesRGB 与 Display-P3:当 rgb(255 0 0) 不是最红的红
本页所有 hex 和 rgb() 值都生活在 sRGB 里 — 一个 1996 年围绕 CRT 荧光粉标准化的色域(gamut)。显示器早已向前走了:iPhone 7 之后的所有 iPhone、2016 年之后的所有 MacBook,以及大多数旗舰 Android 和高端 Windows 面板都覆盖 Display-P3,它比 sRGB 多容纳约 25% 的颜色,增益最大的是高饱和的红、橙、绿。CSS Color 4 让这些颜色可以被直接指定:color(display-p3 1 0 0) 在 P3 屏幕上渲染出比 rgb(255 0 0) 明显更深的红,而在 sRGB 屏幕上两者收敛为同一个被裁剪的值。
这种差异会以隐蔽的方式渗入日常工作流。macOS 截图被标记为 Display-P3,所以从截图里取的色值和生成它的 CSS 在数值上对不上。设为 P3 的 Figma 文档在导出或在 sRGB 硬件上查看时会偏色 — 经典的"在我的 Mac 上过饱和、在办公室显示器上发灰"的 bug 报告,根源是 gamut 转换而不是你的 CSS。还有一些品牌色,尤其是高饱和橙色,完全落在 sRGB 之外,没有任何 hex 码能忠实表示它们。
实用建议:把 sRGB 值作为基线,把广色域颜色放在 @supports 或 @media (color-gamut: p3) 检查后面叠加;并记住旧浏览器会跳过无法解析的声明 — 先写 sRGB 回退,下一行写 color(display-p3 ...) 覆盖,优雅降级就白送了。
派生 Hover、Active、Disabled 与暗色模式颜色
转换到 HSL 最日常的用途是派生交互状态。把明度移动 8–10 个点 — hover 更深一点,active 再深一点 — 在 L 40–60% 的中间调上效果很好。但在极端值附近会失效:L 92% 时 +8 会被裁剪成纯白,而在 L 10% 附近同样的移动会彻底改变颜色的性格。把派生明度限制在大约 5–95% 的区间内,碰到边界时改为调整饱和度。
暗色模式不是明度取反。翻转 L 能保住 hue,但会得到纯黑表面上的纯白文字 — 这在 OLED 面板上会产生 halation(光晕),对散光用户的可读性下降是可测量的。Material Design 的建议是接近 #121212 的 near-black 表面而不是 #000000,配合降低饱和度的强调色 — 在白底上恰到好处的高饱和品牌色,放到深色表面上会显得刺眼发颤。不要原样复用亮色模式的 token,而是降低 chroma、略微提高明度。
禁用(disabled)状态优先用 alpha 而不是更浅的灰色:38% 不透明度的元素在任何背景上都能被识别为禁用,而硬编码的 #C0C0C0 一旦放在带色调的卡片上就穿帮了。
最后注意舍入。这个转换器和 CSS 一样,把 H 舍入到整数度、S/L 舍入到整数百分比 — 大约 360 万个 HSL 格点对应 1670 万个 RGB 值。因此往返转换可能让某个通道漂移 ±1。保存一种规范格式(通常是 hex)作为唯一事实来源,其余格式从它派生,不要来回反复转换。