Skip to content
返回博客
教程

OKLCH 色彩空间详解 —— Tailwind v4 为什么采用它

OKLCH 如何在 2024–2026 年成为设计系统标准。它与 HSL、LCH 有何不同,附一份从 HEX 到 OKLCH 的完整换算演练。

14 分钟

OKLCH 色彩空间详解 —— Tailwind v4 为什么采用它

打开任意一个 2025 年代设计系统的源码 —— shadcn/ui、Radix Themes、Tailwind v4 默认调色板 —— 第一眼跳出来的就是颜色。不是 hex 代码、不是 hsl() 三元组,而是一个三年前几乎没人聊起的函数:oklch()。Tailwind v4 把整个默认调色板都用 OKLCH 字面量交付。shadcn 现在生成的主题也输出 OKLCH 自定义属性。Vercel 的设计系统在 2024 年围绕它重构了一遍。

这并不是赶时髦。每一个认真的设计系统悄悄切换色彩模型,背后都有一个具体的、数学层面的理由 —— 一旦你看清楚了,就再也无法对 HSL 视而不见:它一直就不该被我们这样用。

本文从第一性原理梳理这个理由,以一段从 hex 到 OKLCH 的完整换算结尾,并给出一份可以套用到自家调色板的迁移配方。

色彩空间是从什么时候开始崩坏的

设计系统有一项「色调级差」工作。按钮悬停时要比静态状态稍亮一点。一张静音卡片要比周围的表面颜色暗一档。焦点环要比身后的中性 chrome 明显更亮。要在规模化场景里把这件事做好,就要求「更亮」和「更暗」在调色板里的每一种色相上意味着同一件事。

这个要求,当调色板只有八种颜色、三种状态时,很容易被忽略。当团队开始交付 11 档色阶(Tailwind 约定里的 50–950)、八种语义颜色、亮/暗双变体、以及必须与 iOS、Android、Web 系统色共存的品牌强调色时,这件事就变得难受起来。突然之间,「这个 teal-500 是不是和我们的 blue-500 同样亮」就成了实打实的工程问题,而不是艺术指导的奢侈品。

HSL —— 从 CSS 3 时代一路扛过来的主力模型 —— 无法回答这个问题。两个 L 值完全相同的 HSL 颜色,看上去的感知亮度可以差出很多。一个纯 HSL 黄在 lightness: 50% 时,看起来比同样 lightness 的纯 HSL 蓝亮得多。你的眼睛并不把黄和蓝感知为等价亮度;HSL 当初的设计目标是对取色器直觉友好,而不是让色阶在感知上一致。到 2023 年,每一个规模超过几把颜色的设计系统,都在靠自定义混色脚本或手工微调来打补丁。

我们真正需要的色彩模型,是 L 真的能代表「人会报出的感知亮度」,并且旋转色相或降低饱和度不会以副作用的方式悄悄改变亮度。这种模型在学术色彩学里早就有了 —— 只是还没进 CSS。

把 HSL 的问题摊开看

把下面这几个丢进浏览器,并排看一眼:

.a { background: hsl(60 100% 50%); }   /* yellow */
.b { background: hsl(240 100% 50%); }  /* blue */
.c { background: hsl(120 100% 50%); }  /* green */

三者都是 L: 50%,没有一个看起来亮度一致。黄色几乎刺眼;蓝色在白页面上几乎读作黑;绿色坐在两者之间。如果你给 hover 状态加 10% 的 L,黄色的 hover 几乎看不见,而蓝色的 hover 是一记戏剧性的位移。你的交互打磨,最终取决于设计师碰巧从哪种色相起步。

这并不是 HSL 的 bug。HSL 是 1978 年为 paint-by-numbers 取色器设计的,用户通过操作色相、饱和度和「lightness」 —— 定义为 (max(R,G,B) + min(R,G,B)) / 2 —— 来调出一个颜色。这套数学里没有人类感知的概念。HSL 里的 lightness,只是 sRGB 通道的一个几何中点,仅此而已。

CIE —— 国际色彩计量标准机构 —— 早在 1970 年代就知道这个问题。他们发表了两个感知均匀的色彩空间,CIELAB 与 CIELUV,把感知亮度定义成更接近人眼实际行为的东西。到了 1990 年代,CIE LAB 已经成为印刷、摄影和色彩管理的标准。但它到 RGB 的换算很别扭,CSS 始终没大规模采纳。Web 开发者继续使用 HSL,不是因为它对,而是因为它就在那儿。

CIE LAB / LCH:学术解法,又带着自己的问题

CIELAB 接受一个 XYZ 三刺激值(描述人眼锥细胞如何响应光的模型),做一次立方根和一次二维旋转,产出三个通道:L*(感知亮度,0–100)、a*(绿 ↔ 红)、b*(蓝 ↔ 黄)。LCH 是同一空间的极坐标形式:L*C*(色度 chroma,距中性色的距离)、H*(色相角)。

这些空间在可度量意义上是感知均匀的。ΔE 为 1 —— LAB 空间中沿任意方向的一个单位步长 —— 大致是受训观察者能察觉到的最小色差。印刷与印前工作流靠 LAB 与 LCH 跑了几十年。

那 CSS 为什么没直接采纳 LCH 收工?

两个原因。其一,CIE LAB 是针对一组特定观察条件(D50 照明下的 2° 标准观察者)校准的,这套校准为表面反射光优化,而不是为发光屏幕。在屏幕上,它的感知均匀性会漂移 —— 在 LAB 中「等亮」的颜色,在手机上未必看起来等亮。其二,LCH 的色域很别扭。有些可见颜色 LAB 能很好地描述,但落在常见显示色域之外;而 LCH 到 sRGB 的映射偶尔会出现色相漂移(你减小蓝色的色度,它会微微偏紫)。对设计系统而言,这两点都是 deal-breaker。

CSS Color 4 确实在 2021 年加入了 lab()lch(),它们在现代浏览器里也确实能用。但具体到在发光屏幕上构建一致的色调级差这件事,社区一直在继续找答案。

OKLAB / OKLCH:Ottosson 2020 年的洞见

2020 年 12 月,瑞典色彩工程师 Björn Ottosson 发表了一篇题为《A perceptual color space for image processing》的论文。论文很小:三个短矩阵,一个立方根步骤,没有校准表,没有有版权的参考数据。Ottosson 拿来了已有的 IPT 和 CAM16-UCS 色彩模型 —— 性质不错但数学很糟的两个学术空间 —— 派生出一个更简单的空间,用线性光 XYZ 三刺激值上的普通矩阵乘法,就能近似它们的感知行为。

他把它叫做 OKLAB。极坐标形式就是 OKLCH。

OKLCH 特别的地方不是新奇 —— 而是恰好契合目的。三个性质一起到位:

  1. OKLCH 的感知亮度是真感知的。 在校准过的显示器上,L: 0.7 的纯黄和 L: 0.7 的纯蓝看起来同样亮。把 hover 状态定义为 L + 0.05,整个调色板上得到的视觉位移都是等价的。
  2. 色相在色度变化下保持稳定。oklch(0.7 0.2 30) 的 C 降到 oklch(0.7 0.1 30),色相纹丝不动。在 LCH 里,同样的操作往往会引入可见的色相漂移。在 OKLCH 里,你可以压平色度来构造一个品牌色的「静音」变体,而不必担心它意外漂向别的色相。
  3. 数学便宜。 两次矩阵乘法加一次立方根。30 行 JavaScript 就能实现,没有查找表,没有按设备校准,没有授权问题。

这三件事合起来,才让 OKLCH 在真实 CSS 里可用。W3C 在 2022 年把 oklch() 加进 CSS Color 4。Chrome 111 在 2023 年发了它。到 2024 年中,每一个常青浏览器都支持。同年 Tailwind v4 把它定为默认调色板格式。

数学:一段完整的 HEX → OKLCH 换算

我们走一遍 #3b82f6 —— Tailwind 的 blue-500 —— 到 OKLCH。这正是 颜色转换器工具HEX 转 OKLCH 辐条 每次按键时跑的同一套数学。明白引擎盖下发生了什么,下一节里讲的那些坑就会更好理解。

第 1 步:Hex 到 sRGB。 把 6 位 hex 拆成三对,各除以 255。

const r = 0x3b / 255; // 0.231
const g = 0x82 / 255; // 0.510
const b = 0xf6 / 255; // 0.965

这是gamma 编码的 sRGB 值:图片文件存储的就是这种带非线性曲线的通道值,曲线是为补偿显示器发光方式而烘焙进去的。

第 2 步:sRGB 到线性 sRGB。 去掉 gamma 曲线,得到线性光通道值。CSS Color 4 §11.2 规定的标准分段变换:

const linear = (v) => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
const [lr, lg, lb] = [r, g, b].map(linear);
// lr ≈ 0.045, lg ≈ 0.220, lb ≈ 0.923

第 3 步:线性 sRGB 到 XYZ D65。 一次标准矩阵乘法,定义见 CSS Color 4 §15.1:

const x = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
const y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb;
const z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb;
// x ≈ 0.265, y ≈ 0.231, z ≈ 0.927

这就是规范的 XYZ 三刺激表示 —— 那种「这个颜色由哪些波长组成,从人眼锥细胞响应角度看」的形式。

第 4 步:XYZ 到 LMS。 Ottosson 的第一个矩阵把 XYZ 映射到为 OKLAB 调过的长/中/短锥基础空间:

const lms = [
  0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z,
  0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z,
  0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z,
];

第 5 步:对 LMS 取立方根。 这就是感知压缩步骤 —— 类比于 CIE LAB 里的立方根:

const lms_ = lms.map(Math.cbrt);

第 6 步:LMS’ 到 OKLAB。 Ottosson 的第二个矩阵:

const L = 0.2104542553 * lms_[0] + 0.7936177850 * lms_[1] - 0.0040720468 * lms_[2];
const a = 1.9779984951 * lms_[0] - 2.4285922050 * lms_[1] + 0.4505937099 * lms_[2];
const b_ = 0.0259040371 * lms_[0] + 0.7827717662 * lms_[1] - 0.8086757660 * lms_[2];
// L ≈ 0.629, a ≈ -0.022, b_ ≈ -0.191

这就是 OKLAB 了。感知亮度通道 L ≈ 0.629 就是眼睛在这只特定蓝色上感知到的「60% 亮度」。

第 7 步:OKLAB 到 OKLCH(笛卡尔到极坐标)。

const C = Math.sqrt(a * a + b_ * b_);           // 0.193
const H = (Math.atan2(b_, a) * 180 / Math.PI + 360) % 360; // 263.4
// → oklch(0.629 0.193 263.4)

于是 #3b82f6 就是 oklch(0.629 0.193 263.4)。感知亮度 0.629,色度 0.193,色相 263.4°。

如果你只在 L 上做变化(从 0.95 到 0.15 分 11 档),固定 C 和 H,从这个颜色构造一份 50–950 色阶,你会得到一份每一档都明显是同一色相、感知亮度均匀渐变的调色板。在 HSL 里做同样的事,深色档会向紫色漂,浅色档会变灰。才是真正的收益。

Display P3 + Rec2020:OKLCH 为什么解锁广色域

OKLCH 是无界的。不像 HSL(被钉死在 sRGB 上),甚至也不像 LCH(为反射表面校准),OKLCH 没有隐式色域。你可以写 oklch(0.7 0.25 30),得到一抹艳丽的红,它落在 Display P3 的色彩体内、但在 sRGB 之外。在最近几年的 iPhone 或 MacBook 上,它真的会渲染出来。在更旧的显示器上,浏览器会自动把它捕捉到最近的 sRGB 表示。

这件事重要,是因为 Apple、Samsung 与 W3C 在 2010 年代后期都在交付广色域硬件。配 mini-LED 的 14 寸 / 16 寸 MacBook Pro 默认就是 P3。iPhone 15 Pro 在 Safari 中渲染 Display P3。Android 旗舰交付 Rec2020 面板。到 2025 年,设计系统流量里有相当大一部分,跑在 HSL/sRGB 根本表达不出来的广色域硬件上。

OKLCH 让你写这些颜色,而不必另外挂一段 @media (color-gamut: p3) 声明。浏览器替你处理回退。你的设计系统开箱即得「用这台设备所能渲染的最艳红」。

这也是为什么 OKLCH 是设计 token 的正确格式。一个写成 OKLCH 的 --brand 变量,是对意图的设备无关描述。无论用户的显示设备是什么,浏览器都自行决定渲染什么;你的代码在 CSS、SwiftUI(原生支持 Display P3 Color)、Android Compose(感知 Rec2020)与 Flutter 之间都可移植。

Tailwind v4 与设计 token 革命

Tailwind v4 —— 2024 年发布 —— 是把 OKLCH 从研究推向行业默认的拐点。Tailwind 作者做了三个有立场的判断:

  1. 默认调色板是 OKLCH。 Slate、gray、zinc、neutral、stone —— 源码里每一个 Tailwind 颜色都用 oklch() 定义。50–950 色阶在构造上就保证了感知亮度的均匀。
  2. 自定义主题用 @theme 块加 OKLCH 字面量。 品牌色定义为 oklch() token;下游工具类(bg-brand-500text-brand-300)由此生成。
  3. 不需要 fallback 仪式。 不支持 OKLCH 的浏览器,在 Tailwind v4 文档的基线之下。

最后这个决定,正是让采用本身成为「不容易踩脚」的选择。就在 2023 年,设计师还得为每一种颜色同时交付 oklch()hsl() 两个版本,以免老版本 Safari 崩。换到 Tailwind v4,基线是 2023 年及以后的浏览器,OKLCH 哪儿都能用。

shadcn/ui 的主题生成器沿用同一模式:输入品牌色,得到一份 OKLCH 色阶。Vercel 的设计系统语义色用 OKLCH。Radix Themes 的色阶用 OKLCH 定义。社区已经收敛。

实操迁移:HEX 调色板 → OKLCH 调色板

如果你今天用的是基于 hex 的调色板,迁移是机械化的。配方如下:

1. 定下色阶结构。 Tailwind 的 50–950(11 档)是事实默认,除非有特别理由,否则照搬。L = 0.97、0.93、0.86、0.76、0.63、0.50、0.42、0.34、0.26、0.18、0.10 这几档能产出一份平滑的感知级差。

2. 把品牌 hex 转成 OKLCH。颜色转换器HEX 转 OKLCH 工具。你会拿到一组三元组,比如 oklch(0.629 0.193 263.4)。记下 H 值 —— 这就是你的品牌色相。

3. 固定 C 和 H,只动 L。 用如下方式发出色阶:

--brand-50:  oklch(0.97 0.193 263.4);
--brand-100: oklch(0.93 0.193 263.4);
...
--brand-950: oklch(0.10 0.193 263.4);

4. 微调极端档。 在很低 L(≤ 0.20)与很高 L(≥ 0.95)上,高色度值会落到 sRGB 之外。把那些档的 C 降一点,或者接受浏览器自动 snap。Tailwind 默认就在两端降色度 —— 照搬这个模式。

5. 定义语义别名。 --surface: var(--brand-50)--surface-elevated: var(--brand-100)--text-primary: var(--brand-900) 等等。现在你的设计 token 读起来是意图,而不是颜色。

6. 校验对比度。 用 APCA Lc 或 WCAG 2 对比度比率,确认每一对 --text-*--surface-* 都达到你的无障碍门槛。因为 OKLCH 的 L 是感知的,对比度数学比 HSL 下更可靠。

一支团队在一份 60 色的遗留调色板上做这种迁移,典型情况是在一个下午就落地一份更小、更均匀、30–40 个 token 的 OKLCH 调色板。新调色板交付得更小,生成的 CSS 也更小,在 hover 与禁用状态下产出的色调位移在视觉上明显更好,且不需要任何额外微调。

陷阱与处理方式

入门前几件要知道的事:

超出色域警告。 部分 OKLCH 值会落到 Display P3 或 sRGB 之外。现代浏览器会自动 snap 到最近的合法颜色,但这一步是有损的:你写的 oklch(0.7 0.25 30) 可能渲染得比你想的略不饱和。像 颜色转换器的色域行 这样的工具会告诉你当前颜色是 sRGB 安全、P3 安全还是 Rec2020 安全,并提供一键 snap 到 sRGB,所写即所见。

亚像素色度的怪味。 OKLCH 的色度无上界,但有用的范围对可见颜色大致是 0 到约 0.4。0.4 以上的值,只有单色激光才能实现 —— 那些不是显示器能渲染的物理颜色。颜色转换器把色度滑块封顶在 0.4,正是这个原因;超过它,在任何真实显示设备上都看不出差别。

2025 年式的浏览器支持。 Chrome 111+、Safari 15.4+、Firefox 113+ 都原生支持 oklch()。2023 年之前的浏览器不支持。如果你必须支持遗留的 IE/Edge 或更老的移动 Safari(视受众不同,占流量 1–3%),可以用 @supports (color: oklch(0 0 0)) 把一条 OKLCH 声明和 hex 兜底配对 —— 但对在 2025 年交付的设计系统 token 来说,兜底的成本通常超过遗留收益。

Hex 代码不会消失。 OKLCH 是给设计系统意图用的。出于历史原因,你的 CMS 可能仍然需要一个 hex 值(邮件签名、Office 文档、品牌资产清单)。维持一份生成的查找表,为每一个 OKLCH token 输出 snap 到 sRGB 的 hex,但不要用 hex 创作。

别把 OKLCH 和 OKLAB 混为一谈。 OKLAB 是直角形式(L、a、b 通道);OKLCH 是同一色彩空间的极坐标形式(L、C、H)。一步笛卡尔↔极坐标转换就能互换。token 用 OKLCH(更可读、更容易做色阶);需要插值或混合颜色的内部计算用 OKLAB。

在你自己的调色板上试一试

要看到本文描述的东西真正生效,最快的办法是把一个品牌 hex 丢进 颜色转换器。把品牌色键进 HEX 字段,读一下 OKLCH 输出。然后在 OKLCH 一侧拖动滑块,看着你减小色度时同一个色相一直是同一个色相,看着你旋转色相时同一感知亮度一直是同一感知亮度。几分钟之后,你会对「HSL 一直就不是做色调级差的工具」以及「每一个认真的设计系统为什么都已经搬家」形成一份直觉。

要做具体的 HEX 到 OKLCH 换算,用 HEX 转 OKLCH —— 和本文同一套数学,外加一项好处:它会显示色域分类(sRGB / Display P3 / Rec2020),让你知道自家品牌色里哪些是哪儿都安全的,哪些需要广色域设备才能完整渲染。

这就是 OKLCH。值得迁移。做得好,你以后再也不会写 hsl() 了。