HEX、RGB、HSL 与更新的颜色空间
CSS 里的一种颜色是三个或四个数字,外加一条读取它们的规则,也就是颜色空间。在屏幕上看起来不均匀的配色,通常是在一个与视觉工作方式不匹配的空间里等距取值得到的。
HEX 和 RGB 是同一组数字
#3B82F6 是三个十六进制字节:3B 是 59,82 是 130,F6 是 246,所以 rgb(59, 130, 246) 就是同一种颜色的十进制写法,颜色转换器在两者之间来回转换不会有任何损失。
三位简写是把每一位重复一遍,而不是补零,所以 #F80 展开成 #FF8800,不是 #F08000;只有 4096 种颜色有简写形式。#RGBA 以同样的方式加上一位 alpha,#RRGGBBAA 则把四个字节全部写出来,其中那个 alpha 字节的范围是 0 到 255(1A 是 10%,80 是 50%,CC 是 80%),而 CSS 里其他所有 alpha 的范围都是 0 到 1。现代语法还接受用空格分隔的分量,alpha 写在斜杠后面:rgb(59 130 246 / 50%)。
这些数字并不是光的量。sRGB 通过一条大约 2.2 的传递曲线来编码每个通道,所以 128 发出的光大约是 255 的 21.6%,而不是一半,任何在原始数值上做的混合都会继承这条曲线。
HSL、HSV,以及那个并非亮度的亮度
HSL 把同一个 RGB 立方体重新排成一个圆柱:色相是一个角度(0 是红,120 是绿,240 是蓝),饱和度是到中央灰轴的距离,亮度则从 0 经过 50% 的纯色相一直到 100% 的白。HSV 把亮度换成了明度,明度的顶端是纯色相而不是白色,所以浅色调要靠降低饱和度得到。这两者对感知都一无所知。
| 颜色 | HSL | 相对亮度 | OKLCH 亮度 |
|---|---|---|---|
#FFFF00 | hsl(60 100% 50%) | 0.928 | 0.97 |
#0000FF | hsl(240 100% 50%) | 0.072 | 0.45 |
两者都自称 50% 亮度;其中一个发出的光大约是另一个的十三倍。一行把每种色相都钉在 50% 的设计令牌,读起来就是一堆轻重不一的杂音。
在固定色相和饱和度的前提下逐档改变 HSL 亮度,失败的原因是一样的,而且还多一条:饱和度是相对于该亮度下可用的空间来度量的,所以 50% 那一档最鲜艳,两边的都会褪色。色相角度保持不变,感知到的彩度却在坍塌,这正是中间几档看起来发浑的原因。它们不是基准色被调暗,而是基准色被灰化了某个没人选定的量。
黄色把这件事的最坏情况展示得最清楚。降低 HSL 亮度会把黄色一路走成橄榄绿,因为在那个色相角度上的深黄本来就是橄榄绿,而想让它保持黄色,就得在下降的同时把色相往橙色方向旋转。蓝色的麻烦正好相反,它的亮度太低,能用的变化全都住在 50% 以上。
LCH 与 OKLCH
LCH 是 CIE Lab 的极坐标形式,建立在配色实验之上:亮度 0 到 100,彩度(色彩强度,没有固定上限),以及一个色相角。OKLCH 是同样的形状,只不过建立在 Oklab 之上,它纠正了 Lab 众所周知的色相偏移,其中最明显的就是蓝色在变浅时往紫色漂移。
.a { color: oklch(0.62 0.21 260); } /* L 0 to 1, C about 0 to 0.37, H in degrees */
.b { color: oklch(62% 0.21 260 / 50%); } /* percentages and alpha both allowed */
.c { color: lch(52% 82 285); } /* L runs 0 to 100 here */
感知均匀意味着相等的数值距离接近于相等的感知距离。以 0.08 的亮度步长做出来的色阶看上去就是均匀的,一排钉在同一亮度上的色相读起来就是整齐的一排,而改变亮度不会把色相挪走。插值也可以使用这个空间,比如 linear-gradient(in oklch, #0000FF, #FFFF00)。有一点要注意:OKLCH 的亮度比 HSL 更接近视觉上的明亮程度,但它不是 WCAG 的亮度公式,所以文字配色仍然要过一遍对比度检查工具。
色域与裁剪
色域是一个空间能表示的颜色集合。sRGB 是长期以来的默认色域;Display P3 大约多覆盖四分之一的可见颜色,增益几乎全在深红和深绿上。OKLCH 的彩度没有上界,所以写出一种任何屏幕都显示不出来的颜色是很容易的。
@supports (color: color(display-p3 1 1 1)) {
.vivid { background: color(display-p3 0.9 0.1 0.14); }
}
逐通道裁剪把超出色域的颜色拉回来的办法,是各个通道分别钉在自己的上限上,这会让色相偏移,并把相邻的几档色压平成同一个值,于是渐变出现色带、细节消失。CSS Color 4 采用的是映射:保持亮度和色相不变,在 OKLCH 里降低彩度,直到颜色能装得下。
这也是为什么一张色彩鲜艳照片的截图会显得平淡。照片带着 P3 描述文件,显示器把它完整地呈现出来;而转换成 sRGB 的导出版本,把那些饱和的像素挤进了一个更小的体积里,红色和绿色要走的路又最远。
Alpha 合成
一种半透明颜色叠在背景之上会产生第三种不透明的颜色,逐通道计算:
result = alpha * source + (1 - alpha) * backdrop
50% 的黑叠在白上给出 (127.5, 127.5, 127.5),也就是 #808080。“黑色 50% 不透明度就是灰色”这个说法正是来自这个巧合,而它只在白底上成立,别处都不成立:同样的黑叠在 #3B82F6 上给出大约 #1E417B,一种深蓝色,叠在一张照片上则每个像素给出的颜色都不一样。半透明的黑是一个调暗算子,不是一种灰色。
合成是在编码后的数值上进行的,所以那个 #808080 的相对亮度大约是 0.216,而不是 0.5。要测量合成之后的十六进制值,绝不要测量声明的颜色。alpha 的叠加也不像人们预期的那样:两层 50% 覆盖的是 75%,因为第二层只作用在第一层放过去的那部分上。
浅色调、深色调、灰色调与配色和谐
| 术语 | 传统调法 | 在 OKLCH 里 |
|---|---|---|
| 浅色调(tint) | 基准色加白 | 提高 L,略微降低 C |
| 深色调(shade) | 基准色加黑 | 降低 L |
| 灰色调(tone) | 基准色加灰 | 保持 L 不变,降低 C |
配色和谐就是色相偏移。它们能挑出一个强调色;它们建立不起一套体系。
| 和谐类型 | 偏移量 | 适合什么 |
|---|---|---|
| 互补 | +180° | 在一个主导色相之上配一个强调色,不过两者都饱和时会有跳动感 |
| 邻近 | ±30° | 平静的分组、背景,以及同一族内部的各种状态 |
| 三分 | +120°、+240° | 图表系列这类分类集合 |
RGB 色轮上的偏移量在感知上并不均匀,因为那个色轮在绿色上占据了过大的弧度,所以把算出来的和谐配色当作初稿,再用眼睛调整。用一个基准色生成浅色调、深色调和和谐配色的调色板生成器,能很快把这份初稿摆到屏幕上。
中性色阶需要单独做决定。纯灰,也就是红绿蓝相等的颜色,摆在一个有颜色的界面旁边会显得死气沉沉、隐隐发脏。给中性色一点点彩度,在 OKLCH 里大约 0.005 到 0.02,色相靠近品牌色:60° 到 90° 附近的暖灰读起来像纸,250° 附近的冷灰读起来偏技术感。把这点彩度朝最浅的几档逐渐收掉,好让背景看上去不带色偏。
色觉障碍
大约每 12 名男性中有 1 人、每 200 名女性中有 1 人存在色觉缺陷。红绿类型几乎占了全部,其中绿色弱占绝大多数;蓝黄类型的第三色觉异常影响的人不到万分之一。
在界面里真正要紧的混淆是:亮度相近的红与绿、绿与棕和橙、紫与蓝,以及粉与灰。红色盲的视觉还会让红色变暗,于是一个为了表达紧急而选的红色,最后可能成了屏幕上最不起眼的东西。
因此颜色绝不能是意义的唯一载体。给每一种状态都配上文字、图标、形状或位置,并让成对出现的状态在亮度上也有区别,而不只是色相,因为一对 OKLCH 亮度相同的红和绿是最糟糕的组合。把截图转成灰度,几秒钟就能把这类冲突找出来。