lloyyd

OKLCH:让「调亮一点」真的只是调亮一点

interactive · css color level 4 · 8 sections

HSL 里把色相从蓝转到黄,亮度数字没变,眼睛看到的亮度却差了一倍。OKLCH 修好的就是这件事——它的三个轴对应人眼实际感知到的三件事,所以你改一个轴,只有那一件事会变。

这份手册按「先看见问题 → 再理解坐标 → 再落到 CSS」的顺序排。每一节的演示都是活的,可以拖;文中所有数值都由 Oklab 官方转换矩阵实算得出,包括几个和流传说法不一致的地方,我在正文里标了出来。

提出者 Björn Ottosson · 2020 年 12 月 进入 CSS 2021 年 12 月草案 浏览器 Chrome 111 / Safari 15.4 / Firefox 113

01先看见问题

同一个亮度数字,两种亮度

下面两条色带,色相都从 0° 扫到 360°,亮度参数全程锁死不动。右边是把它们去色后的样子——去色暴露真实的感知亮度。

色相扫描:HSL 与 OKLCH 对照

灰度镜像 = 感知亮度
HSLL 锁 50%
S 锁 100%
↑ 去色后
OKLCHL 锁 0.70
C 锁 0.12
↑ 去色后

正在计算…

HSL 那条灰度带像斑马线:黄色区亮得刺眼,蓝色区暗成一块。这就是为什么 lighten() 之类的函数在不同色相上结果完全不可控——HSL 的 L 是「红绿蓝三个数的中点」,一个纯数学量,和眼睛无关。OKLCH 那条灰度带是一整片平的。

这个差异不是审美问题,是工程问题。设计系统里但凡有「hover 时变亮 5%」「danger 色和 primary 色要看起来一样重」这类规则,在 HSL 下就必须为每个色相手工微调,而且改一个色就要重调一遍。

HSL 还有第二个毛病:调饱和度会顺带改色相。Evil Martians 举过一个例子,把 lch(0.35 C 300) 的饱和度从 110 降到 40,蓝色一路漂成了紫色。而 OKLCH 的 C 轴是正交的——降饱和只是降饱和。

02坐标系

L、C、H 各管一件事

OKLCH 是 Oklab 的柱坐标写法:同一根 L 轴,把 Oklab 的 a/b 直角坐标换成了「离轴多远(C)+ 朝哪个方向(H)」。三个轴分别对应人描述颜色时会说的三件事。

拆解器

拖动任一轴,观察另外两个维度是否被牵动
oklch(0.70 0.12 250) #61a3e6
L 感知亮度 0.700
C 色度/浓度 0.120
H 色相角 250°
滑块轨道会实时重绘成「另外两轴不动时,这一轴的全部取值」,超出 sRGB 的部分画成斜纹。
范围含义拿它做什么
L 0 – 1
或 0% – 100%
感知亮度。0 是黑,1 是白 整套明暗阶梯、深浅模式互换、对比度的第一近似
C 0 – 0.4
百分比时 100% = 0.4
离灰轴的距离。0 就是纯灰 主色/柔和色/禁用态的浓淡差别
H 0 – 360
可写 deg / rad
色相角。0° 与 360° 同色 配色关系:互补 +180°,三分 ±120°

色相角不通用

OKLCH 的 0° 不是红色,是洋红;红大约在 29°,黄 90°,绿 142°,蓝 264°,紫 328°。把 HSL 的角度直接搬过来一定错位。

C 的上限为什么写不死

规范说 C 理论上无上限,MDN 给的实用区间是 0 到 0.4。真实上限取决于「这个 L 和这个 H 组合下,屏幕还能不能显示出这么浓的颜色」——每个色相的天花板都不一样,下一节就在讲这件事。

我用二分法把 sRGB 立方体扫了一遍:sRGB 内能达到的最大 C 是 0.321(洋红方向,L≈0.70),Display P3 内是 0.363(绿色方向,L≈0.85)。所以你在 CSS 里写 oklch(0.7 0.35 264) 这种值,浏览器只会把它映射回能显示的最近颜色。

03色域

天花板是波浪形的

色域(gamut)是「能显示哪些颜色」的范围,色彩空间(color space)是「怎么给这些颜色编号」的坐标系。OKLCH 是坐标系,sRGB 和 P3 是范围——坐标系能写出范围之外的编号。

各色相的色度天花板

横轴色相 0–360°
切片亮度 L 0.70

两条曲线的形状说明了一切:天花板随色相剧烈起伏。在 L=0.70 这一层,绿色方向(142°)能到 C=0.236,而青色方向(180°)只能到 0.127——差了将近一倍。这就是为什么「所有主色都用 C=0.2」这种规则会翻车:一部分色相根本达不到。

P3 那条线整体更高,红、绿、洋红方向的增益最明显。这部分多出来的颜色,在 Mac、iPhone、新款 iPad 上是真能看到的,在老式 sRGB 屏上会被压回边界。

色域覆盖典型设备CSS 写法
sRGB基准大多数外接显示器、Windows 笔记本#hex rgb() hsl()
Display P3约 +25~30%Apple 全系、多数旗舰安卓、OLEDcolor(display-p3 …)
Rec.2020更大HDR 电视、专业监视器color(rec2020 …)

oklch() 本身不绑定任何色域——它只是坐标。写下的值落在哪个范围内,由数值决定;超出显示设备能力时,浏览器做色域映射(gamut mapping),把它压到边界上最接近的颜色。

要不要为 P3 单独写一套

多数情况下不必:直接写 OKLCH,让宽色域屏自然吃到更浓的颜色,窄色域屏自动降级。只有当你要在 P3 屏上刻意做得更艳、同时保证 sRGB 屏不失真时,才分开写:

/* 兜底在前,支持者用后面这条 */
color: #c8342a;
color: oklch(0.55 0.19 27);

/* 只在宽色域屏上加浓 */
@media (color-gamut: p3) {
  .brand { color: oklch(0.55 0.24 27); }
}

04插值

渐变里的灰死区

两个颜色之间怎么过渡,取决于在哪个坐标系里画直线。同样的两端,坐标系不同,中间段可以差出一个「泥浆色」。

同两端,四种插值路径

留意中段
起点色相264°
终点色相90°
sRGB 插值把两端的 R/G/B 数字直接线性混合,中间常常掉进一片发灰的低饱和区(蓝→黄尤其明显)。HSL 沿色环走,会扫过一堆无关色相。OKLCH 沿感知均匀的路径走,中段既不发灰也不跑色,亮度全程线性。

CSS 里可以直接指定插值空间,语法是在渐变方向后面加 in <space>

/* 默认:sRGB 插值,中段可能发灰 */
background: linear-gradient(90deg, blue, yellow);

/* 感知均匀插值 */
background: linear-gradient(in oklch 90deg, blue, yellow);

/* 指定色相走短弧还是长弧 */
background: linear-gradient(in oklch longer hue 90deg, blue, yellow);

同一套规则也适用于 color-mix()

color: color-mix(in oklch, var(--brand) 70%, white);

用 oklab 还是 oklch 插值

in oklab 走直角坐标的直线,中段会经过更低的饱和度,过渡最平顺;in oklch 走极坐标,绕着色环转,饱和度全程保持。做柔和过渡用前者,做彩虹/色环用后者。

05语法

写进 CSS

函数形式 oklch(L C H / A),分量之间用空格,透明度用斜杠分隔。

/* 基本形 */
color: oklch(0.7 0.12 250);
color: oklch(70% 0.12 250);        /* L 可用百分比 */
color: oklch(70% 30% 250deg);      /* C 用百分比时 100% = 0.4 */
color: oklch(0.7 0.12 250 / 0.5);  /* 半透明 */
color: oklch(0.7 0.12 250 / 50%);

/* none:该通道无值,参与插值时行为不同于 0 */
color: oklch(0.7 0 none);

相对色语法:真正省事的地方

from 把一个已有颜色拆成 lchalpha 四个变量,然后你只改想改的那个。这是 OKLCH 在设计系统里最大的实用价值——一个基色派生出整套状态色。

:root { --brand: #3b82f6; }

/* 只提亮,色相与浓度原样保留 */
.btn:hover  { background: oklch(from var(--brand) calc(l + 0.08) c h); }
.btn:active { background: oklch(from var(--brand) calc(l - 0.06) c h); }

/* 只抽掉浓度,做禁用态 */
.btn[disabled] { background: oklch(from var(--brand) l calc(c * 0.25) h); }

/* 只转色相,做互补强调色 */
.accent { background: oklch(from var(--brand) l c calc(h + 180)); }

/* 只改透明度,做焦点环 */
.btn:focus-visible { outline-color: oklch(from var(--brand) l c h / 0.4); }

calc 里不能混百分比

from 拆出的 l 是 0–1 的数值,不是百分比。写 calc(l - 10%) 无效,必须写 calc(l - 0.1)

兼容性与兜底

Chrome 111、Edge 111、Safari 15.4、Firefox 113 起支持,2023 年 5 月进入 Baseline。兜底最省事的办法就是利用层叠——把旧写法放前面,不认识 oklch() 的浏览器会跳过后一行:

.card {
  background: #61a3e6;               /* 旧浏览器停在这 */
  background: oklch(0.7 0.12 250);   /* 新浏览器覆盖 */
}

需要按能力分支时用 @supports;相对色语法的支持面比 oklch() 本身窄一些,值得单独探测:

@supports (color: oklch(from red l c h)) {
  /* 这里才用 from */
}

06实战

用一个公式生成整套色阶

OKLCH 最直接的产出:固定色相、固定一条 L 阶梯,色阶就自己长出来了,而且换任何色相都保持同样的明暗节奏。Tailwind v4 的默认调色板就是这么重做的。

色阶生成器

L 阶梯固定,只换色相与浓度
色相 H250°
峰值浓度 C0.15

每格上方是级数,下方是该色与白字的 WCAG 对比度;达到 4.5 的加下划线。浓度按中段最浓、两端收窄的钟形分布——两端本来就接近黑白,硬堆浓度只会溢出色域。

落到代码里,一套色阶就是一行 hue 变量加一张 L/C 表:

:root {
  --h: 250;
  --c-50: 0.03;  --l-50: 0.97;
  --c-500: 0.15; --l-500: 0.62;
  --c-900: 0.07; --l-900: 0.26;
}
.a { background: oklch(var(--l-50)  var(--c-50)  var(--h)); }
.b { background: oklch(var(--l-500) var(--c-500) var(--h)); }
.c { background: oklch(var(--l-900) var(--c-900) var(--h)); }

换主题色只要改 --h 一个数,整套色阶的明暗关系原样保留。这是 HSL 做不到的——HSL 换色相,明暗关系会整体走样。

深色模式的一个便利

因为 L 是感知量,深浅模式的映射可以写成一条规则:把 L 沿 0.5 翻转、并适度压低 C(暗背景上高浓度色更刺眼)。比逐个手挑深色版省事得多。

07别踩

四个常见误解

OKLCH 解决了很多问题,但它不是万能的,有几处流传的说法需要修正。

一、L 相同 ≠ 对比度相同

这是最值得记住的一条。WCAG 对比度算的是 sRGB 相对亮度(一个加权的物理光量),和 Oklab 的感知亮度 L 是两把不同的尺子。同一个 L,不同色相,对比度实测能差 13%:

颜色(均为 L=0.62, C=0.14)色相对白字的对比度
红方向20°3.90
黄方向90°3.66
蓝方向264°3.70
绿方向142°3.45

所以 OKLCH 让对比度更可预测(同 L 的结果聚在一个窄带里,不像 HSL 那样发散),但不能替代对比度计算。卡在 4.5 边缘的组合仍然必须实测。

二、L=0.5 不是中灰

oklch(0.5 0 0) 渲染出来是 #636363,而不是 #808080。反过来,sRGB 的中点灰 #808080 在 Oklab 里的 L 是 0.60。原因是 L 追的是人眼感知——人眼对暗部更敏感,所以「感知一半亮」的物理亮度远低于一半。从 HSL 迁移时按数字直接搬会整体偏暗。

三、色域映射各家实现不一致

当你写的颜色超出屏幕色域,规范推荐了一套映射算法,但 Chrome 和 Safari 目前用的是更快但更粗糙的做法。后果是超出色域的高浓度颜色在不同浏览器上可能不完全一致。想要跨浏览器像素级一致,就把颜色控制在 sRGB 范围内——上面第 03 节的天花板图可以用来检查。

四、Oklab 本身也不完美

Raph Levien 做过一次系统评测,结论是 Oklab 在色相线性和亮度预测上都显著优于 CIELAB(CIELAB 在蓝色区有严重色相偏移),但也指出传递函数在暗部的对比分辨还有改进空间。它是目前工程上最好用的折中,不是终极答案。

迁移建议

不要用转换工具把现有 hex 一键换成 OKLCH——那只是换了个写法,不会带来任何好处。真正的收益来自重新用 L/C/H 的语言定义色阶:定一条 L 阶梯、定色相、让色值自己算出来。

08接下来

工具与原始资料

动手工具

  • oklch.com — Evil Martians 的取色器,能直接看到色域边界在哪,是最常用的一个
  • huetone.ardov.me — 基于 Oklab 生成整套无障碍色阶,带对比度矩阵
  • npx convert-to-oklch — 批量转换现有代码库的 CLI
  • stylelint-gamut — lint 阶段拦截超出色域的值
  • OkColor — Figma 插件,把 OKLCH 带进设计稿(Figma 尚无原生支持)
  • Color.js / culori — JS 端做转换与色域检测的库

值得读的原文

本页所有色块由浏览器原生 oklch() 渲染;色域边界、对比度与 hex 数值由页内 JS 用 Ottosson 转换矩阵实算,转换结果已对红/绿/蓝/白四个基准点校验通过。

一句话收束

OKLCH 的价值不在于它是「更新的颜色写法」,而在于它把「亮度、浓度、色相」变成了三个真正互不干扰的旋钮。一旦色值可以被公式生成而不是被手工挑选,设计系统的颜色部分就从手艺变成了工程。

← all artifacts