为什么 HSL 会误导:为可访问界面选择正确色彩空间
屏幕上的颜色,是 GPU 每像素发给面板的三个数字。代码里的颜色,是一个字符串。把字符串正确映射成那三个数字——这就是整件事的全部,也是 UI 工程里历史包袱最重的一个角落。CSS 在 2023 年发布了一个修复(`oklch()`),所有浏览器都已支持,而绝大多数样式表至今没在用。
颜色格式之所以让人困惑,不是因为格式多——是因为它们各自对应不同任务,困惑发生在用错的格式做错的任务上。
这篇是田野指南。
屏幕上到底发生了什么
一个屏幕像素是三个子像素——红、绿、蓝。操作系统通过 GPU 把三个数字(每通道一个)发给面板。面板把数字转成光强。三个数字进,一个被感知的颜色出。
这三个数字叫 RGB triple,GPU 默认期望的格式是 sRGB——除非下游链路有色彩管理介入。sRGB 是一个具体的色彩空间:它定义了面板会发出哪种红、哪种绿、哪种蓝,加上一条 gamma 曲线定义数值如何映射到光强。
本文剩下的所有内容,机理上都在讲一件事:怎样紧凑地编码一个 sRGB triple,让人能写出来。
Hex 和 RGB 是同一个东西
#ff0000、#FF0000、rgb(255, 0, 0)、rgb(100%, 0%, 0%)——同一种颜色。Hex 就是用 16 进制写的 RGB,每通道两位。
三位 hex(#f00)是简写:每位重复两次,所以 #f00 = #ff0000、#abc = #aabbcc。当每个通道高低半字节相同时方便。
四位 hex(#f00a)和八位 hex(#ff0000aa)末位是 alpha(透明度)。CSS 在 2018 年前后采纳,现代浏览器都能解析。#rrggbbaa 顺序是 RGBA——alpha 在最后——和 rgba(255, 0, 0, 0.667) 一致。别反过来写。
Alpha 在 hex 中是 0(透明)到 255(不透明),在 rgba() 中是 0 到 1。rgba(255, 0, 0, 0.5) 与 #ff000080 是同一颜色:50% × 255 ≈ 128 = 0x80。
RGB 数值本身是非线性的——见下面的"gamma"。这件事对色彩数学很重要;对挑颜色不太重要。
HSL 和 HSV:最常见的那个谎言
HSL(Hue / Saturation / Lightness)和 HSV/HSB(Hue / Saturation / Value)的设计目标是比 RGB 更直观。卖点:选色相(0–360°)、选饱和度、选亮度。问题:HSL 的 "lightness" 不对应"感知亮度"。
自己证给自己看,CSS:
.a { background: hsl(60, 100%, 50%); } /* 黄 */
.b { background: hsl(240, 100%, 50%); } /* 蓝 */
两个 lightness: 50%。它们不是等亮的。50% 黄刺眼地亮;50% 蓝又暗又饱和。原因:HSL 的 "lightness" 是 RGB 空间里的数学中点,不是人眼感知的中点。黄本质上比蓝亮,HSL 没考虑这点。
这就是为什么"我用 HSL 的 lightness 步进生成调色板"那种项目产出的色卡看起来步距不均匀。lightness 滑块跟不上感知亮度,等步距 lightness 的色卡看起来不等亮。
HSV(Photoshop、GIMP、许多取色器)在 value 上有同样问题。
HSL/HSV 适合:"想用人类可读的方式快速构造一个颜色"。不适合:感知色彩数学(混色、生成等距调色板、计算对比度、做"同一颜色稍微暗一点")。
Gamma:让像素看起来正确的曲线
rgb(0, 0, 0) 到 rgb(255, 255, 255) 这串数字不是线性光强尺度。sRGB 色彩空间定义了一条gamma 曲线——大致 output = input^2.2——在你代码到面板之间的某一处被应用。
为什么:人眼是对数响应。我们对暗调比亮调更敏感。如果把 256 个亮度档位线性分配,暗的一半会有可见的色阶断层、亮的一半浪费分辨率。sRGB 的 gamma 曲线把更多编码值分给暗调——眼睛能注意到的地方。
后果:rgb(127, 127, 127) 不是 rgb(255, 255, 255) 一半亮。它大约是后者的 22%。sRGB 的"中点"在 rgb(186, 186, 186) 附近。
这件事在哪儿咬人:两个 RGB triple 取算术平均得不到感知中点。如果把红和绿在 RGB 空间里平均,得到的是浑浊的暗橄榄绿,不是你期待的鲜亮黄色。要做正确的色彩数学,必须先 sRGB → 线性 RGB,做计算,再转回去。
UI 代码里多数混色都跳过了这一步。结果就是渐变和暗色模式色彩系统那种"看着不太对劲"的感觉。浏览器在 2022 年通过 color-mix() 与 interpolate-in-oklch 修了这件事——只要你显式选择感知色彩空间,数学就对。
OKLCH:现代答案
CSS Color Module 4 在 2022 年加入 oklch(),到 2024 年浏览器普遍支持。OKLCH 是感知色彩空间:
- L(lightness):0 到 1,感知均匀。0.5 意味着"我能看到的最大亮度的一半"。
- C(chroma):0 到约 0.4,色彩鲜艳度。0 是灰,越大越饱和。
- H(hue):0 到 360°。
相对 HSL 的两大胜利:
- L 是感知的。 同一 L 下两个色,感知亮度等同,不论 hue 是什么。把 L 从 0.1 步到 0.9 生成 10 档调色板,肉眼看上去步距均匀。
- 旋转 hue 时亮度保持。 在 HSL 里固定 lightness 改 hue 会改变感知亮度;OKLCH 不会。
代价:数值不像 HSL 那样直观可估。oklch(0.7 0.15 30) 比 hsl(30, 80%, 50%) 难凭直觉读。用工具翻译。胜利在数学里,不在书写里。
CSS color-mix() 函数允许直接在 OKLCH 里插值:
background: color-mix(in oklch, blue 50%, red);
这个中点会看起来像真正的紫色,不是浑浊的灰。设计师手动重建这件事已经几十年了。
如果你 2026 年正在搭设计系统:把 token 定义在 OKLCH。需要时在构建步骤转 hex/RGB。下游所有事——调色板生成、暗色模式翻转、对比度检查——都会更干净。
CMYK:印刷那一个
Cyan(青)、Magenta(品)、Yellow(黄)、Key(黑)。减色法,用于印刷。每通道是 0% 到 100% 的油墨覆盖;四块印版按这个顺序印。
要理解两件事:
- CMYK 的色域比 sRGB 小。 屏幕上能看到的鲜亮蓝绿,印刷里不存在。从 sRGB 转 CMYK 是有损映射,转换需要决定如何处理超色域颜色(裁切 vs 降饱和)。
- CMYK 是设备相关的。** 不带 profile 的 "CMYK"(比如欧洲印刷的 FOGRA39、美国的 GRACoL)是没意义的。两台不同的印刷机给同一组 CMYK 数值会产出不同结果。
只做屏幕的工作就忽略 CMYK。需要印刷就用印刷厂指定的 profile,在 Photoshop 里软打样,并预期颜色比屏幕暗淡——没有"软件修复"能让 sRGB 里的颜色出现在 CMYK 里。
WCAG 对比度:那条即将变化的规则
WCAG 定义了对比度公式,大致 (L1 + 0.05) / (L2 + 0.05),L1 是较亮颜色的相对亮度,L2 是较暗的。公式用 sRGB 颜色的相对亮度(线性 RGB 通道的加权和),是浏览器内置对比度检查器算的东西。
阈值:
- 4.5:1 普通文本(WCAG AA)。
- 3:1 大字号文本或 UI 组件(WCAG AA)。
- 7:1 AAA 级普通文本。
公式是公认近似的。对黑底白字这种高对比组校准得好,对暗色模式的微妙搭配差,对色对色文本更差。下个大版本(WCAG 3.0,2026 年仍在制定)用 APCA(Accessible Perceptual Contrast Algorithm)替换它,APCA 用感知模型,对同样色对给出有时差异巨大的不同数值。
2026 年发布的工作:满足 WCAG 2.x AA 阈值,因为审计与采购按这个走。有余力同时检查 APCA,尤其暗色模式色对——WCAG 2 在那儿最宽松也最不准。
色盲
约 8% 男性和 0.5% 女性有某种颜色视觉缺陷。最常见是红绿混淆(deuteranomaly / protanomaly)。UI 上的关键结论:永远不要把颜色作为唯一信号。红错误和绿成功也应该图标 / 文字 / 位置不同。8 条线 8 种颜色的折线图也应该用线型或 marker 区分。
Coblis、浏览器 DevTools 里的色盲模拟器都很好用。它们是粗略代理——真实色觉缺陷个体差异大,重度患者看到的颜色非常不同——但能抓住常见失败模式。
具体规则:
- 不要把红和绿用于对立含义而不附加其他区别特征。
- 蓝/橙是好对比对,多数 CVD 类型都能看见。
- 变 lightness,不只是 hue。 lightness 被稳定感知,hue 不一定。
常见坑
- 用 RGB triple 计算"颜色相似度"。 用感知空间(OKLCH、Lab)。
- 在 sRGB 里线性插值。 尤其渐变。用 OKLCH 或线性 RGB。
- 用 HSL lightness 步进选调色板。 用 OKLCH 的 L。
- 暗色模式信任 WCAG 2 对比度。 APCA 更接近真相。
- CMYK 没带 profile。 选印刷厂正确的那个。
- 三位 hex 想当四位 alpha 用。
#fff是白,不是透明。 rgba(255, 0, 0, 0.5)放暗色背景上。 结果看起来是暗红,不是粉红——alpha 与背景混合,不是与中间色调混合。- CSS 变量存 hex 字符串而不是分通道。 想用
color: rgb(var(--brand) / 0.5),把通道存成--brand: 220 80 100;,不是--brand: #dc5064;。 - 不同 OS 颜色 profile 让浏览器用不同 sRGB 原色。 广色域显示器会比窄色域显示器更鲜艳地呈现 CSS sRGB 颜色,除非页面显式选
color(display-p3 ...)。 - 从截图取色而不检查截图的色彩 profile。2026 年 macOS 截图常常是 P3,不是 sRGB;粘进 sRGB 流程颜色就略变。
哪个用哪个
| 用途 | 格式 |
|---|---|
| CSS 里临时挑一个颜色 | hex(#dc5064) |
| 设计系统中的色彩 token | OKLCH(oklch(0.7 0.15 30)) |
| 生成步进调色板 | OKLCH(变 L) |
| 在两个颜色之间插值 | OKLCH 或线性 RGB |
| 计算可访问性对比度 | sRGB → 相对亮度 → WCAG ratio |
| 印刷 | 带 profile 的 CMYK |
| 程序化处理图像数据 | 线性 RGB |
| 凭眼调整 | "稍微亮一点"用 HSL 还行 |
| macOS / iOS 广色域 | color(display-p3 ...) |
近十年的叙事弧线是:别再把颜色想成 RGB triple,把它想成一个感知坐标,再以消费方期望的格式表达。CSS 在 2022 年终于把工具原生地给了你。切到 OKLCH 的代价是构建管线里加一行;收益是设计 token 真的可组合、渐变看起来对、调色板不再和你作对。
主要参考资料
用于核对本文技术细节的标准与官方文档。
在所有色彩空间之间互转
本站颜色工具支持 hex、RGB、HSL、HSV、CMYK、OKLCH 在浏览器内互转,提供实时预览与对比度警告。适合在多种格式之间核对设计 token 是否一致。数据不会离开浏览器。
打开颜色工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。