Chapter 02

色彩

01 章把"对比 / 明度差"当成制造层级的杠杆,却没说清这些差怎么量化。这章给出量化工具:一个让"明度"说真话的颜色空间(OKLCH),以及在它之上构建灰阶、语义色、对比与暗色模式的方法。

本章你将建立的 schema

  • 颜色是可度量的:HSL 的"明度"为什么会骗人,OKLCH 凭什么更可靠
  • 一套 UI 调色板的主力是中性灰阶,品牌色 / 语义色只是点缀
  • 对比有客观标准:WCAG 2 的对比度比值,与正在接替它的 APCA
  • 暗色模式是重建一套色板,不是把亮色 invert

工程师对颜色的默认表示是 #3b82f6 这样的 hex。hex 适合机器,不适合人——它无法回答"把这个颜色调暗一点、但保持是同一个蓝"这种日常需求。本章先换一套"给人用"的颜色坐标,再在它上面把 01 章的"明度对比"变成可计算的规则。

2.1颜色是可度量的:HSL 的"明度"会骗人

HSL 给了人可独立调的三个旋钮(色相 / 饱和度 / 明度),但它的"明度 L"是数学量,不是人眼感知的亮度——同一个 L、不同色相,看起来明暗差很多。

为什么需要它

用 HSL 手调一组"看起来一样重"的语义色(成功绿 / 警告黄 / 错误红),结果总是黄的发飘、蓝的发沉。问题不在你的眼睛,在 HSL 这套坐标本身。理解它为什么骗人,才知道该换什么。

底层机制(比文档深一层):HSL 的 L 定义是 RGB 三通道的 (max + min) / 2——一个纯数学中点,和人眼对亮度的感知无关。人眼对不同波长的敏感度天差地别:对黄绿光极敏感,对蓝光迟钝。于是 hsl(60 90% 50%)(黄)和 hsl(240 90% 50%)(蓝)虽然 L 都是 50%,黄色看起来亮得刺眼,蓝色看起来暗沉。"固定 L、扫一圈色相"得到的不是一排等亮度的颜色,而是一排明暗参差的颜色——这正是 HSL 手调色阶永远"哪里不对"的根因。

HSL · 同一个 L = 55%,扫一圈色相 黄绿格子明显发亮,蓝紫格子发沉 → 明暗不齐 OKLCH · 同一个 L = 0.72,扫一圈色相
图 2.1两排色块的"明度数值"在各自空间里都是恒定的。注意:① 上排(HSL)亮度肉眼可见地高低起伏,黄格刺眼、蓝格发暗;② 下排(OKLCH)六格看起来一样重;③ 这意味着想要"一组同等权重的颜色",在 OKLCH 里只需固定 L,在 HSL 里得逐个手调。(若你的浏览器较旧、下排未正确显示,说明它还不支持 oklch,但 2023 年起的浏览器都支持。)
类比 · 带边界

HSL 的 L 像"用字节数衡量文章长度"——技术上有个确定的数,但和"读起来多长"不是一回事。失效之处:这个类比只点出"数值≠感知",不代表 HSL 一无是处;调单一颜色的明暗时 HSL 仍能用,只是做"成组、成阶"就露馅。

与下一节的关系:既然问题是"L 不等于感知亮度",解法就是换一个"L 等于感知亮度"的空间。

2.2OKLCH:让明度说真话

OKLCH 是一个感知均匀的颜色空间;它的三个数 L(明度)/ C(彩度)/ H(色相)互不干扰,相同 L 的不同色相看起来一样亮。

为什么需要它

构建色阶(同色相的明暗梯度)、保证"主色和它的浅色变体对比一致"、做暗色模式——这些都需要一个"L 说了算"的坐标。OKLCH 正是为此而来,而且 2023 年起已是主流浏览器的 baseline,可以直接写进 CSS。

底层机制(比文档深一层):OKLCH 是 Oklab 颜色空间的极坐标形式(L 明度 + C 彩度 + H 色相角)。Oklab 的设计目标,就是让"空间里的欧氏距离"约等于"人眼感知到的颜色差异"——这叫感知均匀(perceptually uniform)。三个直接后果:① L=0.5 在任何色相下感知亮度一致(图 2.1 下排的依据);② 改 H(转色相)不会顺带改变感知明度(HSL 改 H 会让明度漂移);③ L 的等步长 = 感知上的等步长,所以"L 从 0.95 每隔 0.1 取一档"得到的灰阶,每一档的"变暗感"是均匀的。CSS 语法:oklch(L C H),如 oklch(70% 0.15 250)。

oklch-basics.css CSS
/* oklch( 明度  彩度  色相角 ) ;明度可写 0–1 或 0%–100% */
--blue:        oklch(0.62 0.19 255);   /* 一个蓝 */

/* 想要更浅的同色变体:只动 L,C/H 不变 → 仍是"同一个蓝" */
--blue-hover:  oklch(0.70 0.19 255);
--blue-active: oklch(0.54 0.19 255);

/* 想要"一样重"的一组语义色:固定 L 与 C,只换 H */
--success: oklch(0.62 0.16 150);   /* 绿 */
--warning: oklch(0.62 0.16 80);    /* 黄 */
--error:   oklch(0.62 0.16 25);    /* 红 */
/* 三者感知亮度一致,放在一起不会有谁发飘、谁发沉 */
类比 · 带边界

从 HSL 换到 OKLCH,像从"经纬度的度数"换成"实际公里数":后者每一步的距离是真实均匀的,所以"等距"才有意义。失效之处——OKLCH 也有边界:某些 L/C 组合超出屏幕能显示的色域(gamut),浏览器会自动夹回最近的可显示色,极端高彩度时需留意。

想一想

用 oklch() 把上面的 --blue 做出 hover(更亮)和 active(更暗)两个态,你只需要改三个数里的哪一个?

先想 10 秒再展开

只改 L。C(彩度)和 H(色相)保持不变,得到的就是"同一个蓝、只是明暗不同"。这正是 OKLCH 相对 hex 的核心便利:hex 做不到"只调亮度",你得三通道一起算。05 章会用这个特性,把整套交互态(hover/active/disabled)从一个基色 token 推导出来。

与下一节的关系:有了"L 说了算"的坐标,就能系统地造一套调色板——而调色板的主力,出人意料地不是彩色。

2.3构建调色板:灰阶才是主力

一套 UI 调色板的绝大部分面积是中性灰阶(背景 / 表面 / 边框 / 各级文字);品牌色、语义色、强调色只是少量点缀。

为什么需要它

新手以为"调色板 = 挑一堆好看的彩色"。打开任何一个专业产品,真相相反:屏幕上 80%–90% 的面积是白、灰、近黑——文字、背景、卡片、边框、分隔线。彩色只出现在主操作、状态提示、品牌标识这些关键点。先把灰阶造对,界面就稳了一半。

底层机制(比文档深一层):灰阶不是 #ccc / #999 / #666 随手取。两个专业做法:① 灰也带一点点色相——纯中性灰(彩度为 0)会显得"死板、廉价";给灰阶注入极低的彩度(OKLCH 里 C ≈ 0.005–0.02)+ 一个统一色相(偏蓝得冷、偏黄得暖),整套灰就有了气质,且能和品牌色呼应。② 用固定 C、固定 H、阶梯式 L 生成 9–12 档,因为 OKLCH 的 L 是感知均匀的,等差的 L 得到的就是观感等距的梯度。每一档对应一个用途。

neutral-scale.css CSS
/* 一条带"冷意"的中性灰阶:固定 C=0.01、H=255,只阶梯式地降 L */
:root {
  --gray-0:  oklch(0.99 0.01 255);  /* 页面背景            */
  --gray-1:  oklch(0.97 0.01 255);  /* 次级背景 / 卡片底    */
  --gray-2:  oklch(0.93 0.01 255);  /* 分隔线 / 边框        */
  --gray-3:  oklch(0.87 0.01 255);  /* 较重的边框           */
  --gray-4:  oklch(0.71 0.01 255);  /* 占位符 / 禁用文字     */
  --gray-5:  oklch(0.58 0.01 255);  /* 次要文字(满足正文对比)*/
  --gray-6:  oklch(0.44 0.01 255);  /* 正文                */
  --gray-7:  oklch(0.28 0.01 255);  /* 标题                */
  --gray-8:  oklch(0.18 0.01 255);  /* 最深文字 / 近黑      */
}
/* 每一档 L 等差下降,观感上是均匀变暗的一条梯子 */
中性灰阶 · 固定 C/H,只降 L → 观感等距 gray-0 · 页面背景 gray-1 · 卡片底 gray-2 · 边框 / 分隔线 gray-3 · 较重边框 gray-4 · 占位 / 禁用 gray-5 · 次要文字 gray-6 · 正文 gray-7 · 标题 gray-8 · 近黑 每一档 = 一个用途 文字三层(标题/正文/次要) = 01 章的"明度对比建层级"
图 2.2一条灰阶就能撑起一个界面的结构。注意:① 文字用了三档(标题 / 正文 / 次要),这正是 01 章"用明度对比建立层级"的落地;② 背景、边框各有专档,不要拿文字的灰去画边框;③ 整条灰带一点 H=255 的冷意,比纯灰更有质感。

60-30-10 规则:一个画面的颜色配比,约 60% 主导(通常是背景 / 中性)、30% 次要(卡片 / 分区)、10% 强调(品牌色 / 主操作)。这条比例的作用,是从一开始就压住"到处都想上色"的冲动——强调色一旦超过 10%,就不再是强调。

常见失败模式

把品牌色用在大面积背景、又在上面放彩色按钮和彩色图标——结果没有任何东西"跳"出来,因为强调预算被挥霍光了。强调色的力量来自稀缺:全屏都是强调色,等于没有强调色(和 01 章"全部加粗 = 没有重点"是同一条规律)。

与下一节的关系:灰阶里"哪一档文字配哪一档背景才读得清",不能靠感觉——它有客观标准。

2.4对比与无障碍:WCAG 与 APCA

文字与背景的对比必须够,否则一部分人读不清;WCAG 2 用对比度比值(正文 4.5:1),WCAG 3 的 APCA 用更贴近感知的算法。

为什么需要它

对比不足是最普遍的可访问性失败,也是"看着高级的浅灰文字"实际读不清的根因。它还和法规挂钩(很多无障碍合规要求引用 WCAG)。把对比当成一个可计算、可卡线的指标,而不是"我觉得够浅 / 够清楚"。

底层机制(比文档深一层):WCAG 2 的对比度 = (L1 + 0.05) / (L2 + 0.05),其中 L 是相对亮度(relative luminance),范围 0–1,比值范围 1:1 到 21:1。它的已知缺陷:这个公式对深色背景、细字重判断失准——同一个比值,在亮底和暗底给人的清晰度并不相同,暗色模式下尤其明显。APCA(Accessible Perceptual Contrast Algorithm,WCAG 3 草案采用)改进了这点:它把字号、字重、明暗极性(深字浅底还是浅字深底)都纳入计算,输出一个 Lc 值(约 0–106),更贴合实际可读性。落地现状(截至 2026 初):WCAG 2.2(2023 年发布)仍是各国法规引用的落地标准;APCA 仍是草案,适合用来做更精细的判断,但合规仍以 WCAG 2 为准。

表 2.1 · WCAG 2 的两条硬线(最低合规)
内容最低对比度说明
正文(< 18.66px 常规 / < 24px)4.5 : 1AA 级正文标准
大字(≥ 24px 或 ≥ 18.66px 加粗)3 : 1字大可放宽
UI 组件 / 图形(边框、图标、状态)3 : 1常被忽略,但同样要满足

实例:浅灰文字到底浅到哪一步就读不清

达标 · #6b7280,约 4.6:1

这段次要文字在白底上对比约 4.6:1,刚过正文 4.5:1 的线,正常视力和弱视都读得清。

不达标 · #b0b6bf,约 2.0:1

这段文字"看着更高级、更轻",但对比只有约 2:1,远低于 4.5:1——强光下、弱视用户、低端屏幕上会直接读不清。

"高级感"常常诱使人把文字调得更浅。对比线的意义,就是给这种冲动一个硬边界:次要文字可以浅,但不能浅过 4.5:1(正文)/ 3:1(大字)。

最常被漏掉的两处对比

① 占位符(placeholder)和禁用(disabled)文字——默认实现常是浅灰,几乎都不达标,容易被当成"反正不重要"而忽略;② 纯图标按钮和状态色边框——属于"UI 组件",要满足 3:1,但大家只盯着正文。

与下一节的关系:以上全是亮色模式的对比。换到暗色模式,对比规则和色板都要重建——而不是把颜色反过来。

2.5暗色模式:重建,不是反色

暗色模式是另起一套"深背景 + 降饱和 + 用明度而非阴影表达层次"的色板,不是把亮色模式的颜色 invert。

为什么需要它

直接反相(invert)会同时踩中好几个失败:品牌色变得刺眼、阴影彻底失效、纯白文字在纯黑上发光晕。理解暗色模式的几条独立规则,才能做出"看着舒服"而不是"把灯关了"的暗色。

底层机制(比文档深一层),三条:

  • 别用纯黑底配纯白字。 #000 背景 + #fff 文字对比高达 21:1,细字会产生光晕(halation)和视觉振动,长时间阅读刺眼。专业做法:背景用 #121212 一类的近黑,文字用 #e0e0e0 一类的近白,把对比从 21:1 降到 15:1 左右仍然清晰,却不刺眼。
  • 层次靠"更亮的表面"表达,不靠阴影。 暗背景上阴影几乎不可见,所以"越靠上层的元素越亮"成为表达 elevation 的手段——卡片比背景亮一点,弹窗比卡片再亮一点。这与亮色模式正相反(亮色靠阴影 + 越上层越白)。
  • 品牌色和语义色要降彩度、提明度。 亮色模式下饱和的颜色,移到暗底上会过饱和、刺眼、甚至"震动"。OKLCH 让这步很直接:同一色相,降一点 C、升一点 L 即可。
dark-mode-tokens.css CSS
/* 语义 token 指向具体值;暗色模式只重绑这一层(05 章详解三层 token) */
:root {
  --bg:        oklch(0.99 0.01 255);
  --surface:   oklch(0.97 0.01 255);   /* 卡片:比背景更"低" */
  --text:      oklch(0.28 0.01 255);
  --brand:     oklch(0.55 0.18 255);
}
:root[data-theme="dark"] {
  --bg:        oklch(0.18 0.01 255);
  --surface:   oklch(0.23 0.012 255);  /* 卡片:比背景更"亮" → 表达浮起 */
  --text:      oklch(0.92 0.01 255);   /* 近白而非纯白,避免光晕 */
  --brand:     oklch(0.70 0.13 255);   /* 同色相,L↑ C↓ → 暗底不刺眼 */
}
亮色:卡片更白 + 阴影 card 暗色:卡片更亮,无阴影 card
图 2.3同一张卡片的两种模式。注意:① 亮色靠阴影 + "比背景更白"表达浮起;② 暗色反过来——卡片比背景更亮,阴影几乎不参与;③ 这说明暗色不是把亮色调反,而是换了一套表达层次的语言。

2.6收束:颜色决策的固定顺序

把本章压成一个可执行的顺序。造色板时,从结构到点缀地走,而不是从"挑个好看的主色"开始:

  1. 先灰阶:用 OKLCH 固定低 C + 一个色相,阶梯式降 L,造 9 档。它撑起背景 / 边框 / 文字三层——界面的结构。
  2. 再语义色:固定 L 与 C、只换 H,造出"一样重"的 success / warning / error / info。
  3. 最后品牌强调色:1 个主色 + 它的 hover / active(只动 L),严格控制在 10% 面积内。
  4. 全程卡对比:任何"文字 / 背景"组合,正文 ≥ 4.5:1、大字与 UI ≥ 3:1。
  5. 暗色单独重绑:只改语义 token 那一层的值,降饱和、用明度表达层次。

这套顺序回扣 01 章:灰阶的三档文字 = 用明度对比建立的层级;强调色的稀缺 = "全部加强等于没有重点"在颜色上的版本。颜色从来不是装饰,它是层级的一种度量工具。

§本章 self-check

先合上教程,把答案写下来再展开对照。

  1. 为什么用 HSL 手调一组"看起来一样重"的语义色很难?换成 OKLCH 为什么就直接了?(答到机制,不要只说"OKLCH 更好")
  2. 一套 UI 调色板里占面积最大的通常是哪类颜色?为什么纯中性灰反而不如带一点点色相的灰?
  3. WCAG 2 的对比度公式基于什么量?它在暗色模式 / 细字重下为什么判断失准?APCA 改进了什么?
  4. 暗色模式为什么不能简单地把亮色模式 invert?至少说出两个具体失败,以及对应的正确做法。
答案(先做完再展开)
  1. 因为 HSL 的 L 是 RGB 的数学中点,不等于人眼感知亮度;同一 L、不同色相的颜色明暗参差(黄亮蓝沉),所以要"一样重"得逐个手调。OKLCH 感知均匀,固定 L 即得到一组等亮度的颜色,只换 H 即可。
  2. 中性灰阶(背景 / 表面 / 边框 / 三级文字),占 80%–90% 面积。纯中性灰(彩度 0)显得死板廉价;注入极低彩度 + 统一色相(冷或暖),整套灰更有质感且能与品牌色呼应。
  3. 基于相对亮度(relative luminance)的比值 (L1+0.05)/(L2+0.05);它不考虑字号 / 字重 / 明暗极性,对深底细字判断失准。APCA 把这些都纳入,输出更贴感知的 Lc 值(WCAG 3 草案,截至 2026 初未成正式标准)。
  4. invert 会让品牌色刺眼、阴影失效、纯白配纯黑产生光晕。正确做法:① 背景用近黑(#121212)、文字用近白(#e0e0e0),不用纯黑纯白;② 用"更亮的表面"而非阴影表达层次;③ 品牌 / 语义色降彩度、提明度。
进阶挑战 · 刚好够不着

从一个品牌主色,推导出一整套可用的颜色 token

给定品牌主色 oklch(0.55 0.18 25)(一个红)。用 OKLCH 推导出:① 一条 9 档中性灰阶,且带一点点这个红的暖意;② 主色的 hover(更亮)和 active(更暗)两个态;③ 暗色模式下这个主色应该怎么调。每一步都要说清你为什么这么动 L / C / H,而不是只给数值。

提示(卡住再展开)

① 灰阶:固定一个极低彩度(如 C=0.012)+ 与品牌同向的色相(H≈25 偏暖),L 从 0.99 阶梯降到 0.18;暖灰会和红主色自然呼应。② hover/active:C、H 不动,只把 L 从 0.55 调到约 0.62(hover,更亮)和 0.47(active,更暗)——保证始终是"同一个红"。③ 暗色:同色相 H=25,把 L 提到约 0.68、C 从 0.18 降到约 0.13,避免暗底过饱和刺眼;同时确认它与暗色背景的对比仍 ≥ 3:1(作为 UI 强调色)。