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 像"用字节数衡量文章长度"——技术上有个确定的数,但和"读起来多长"不是一回事。失效之处:这个类比只点出"数值≠感知",不代表 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( 明度 彩度 色相角 ) ;明度可写 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 得到的就是观感等距的梯度。每一档对应一个用途。
/* 一条带"冷意"的中性灰阶:固定 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 等差下降,观感上是均匀变暗的一条梯子 */
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 为准。
| 内容 | 最低对比度 | 说明 |
|---|---|---|
| 正文(< 18.66px 常规 / < 24px) | 4.5 : 1 | AA 级正文标准 |
| 大字(≥ 24px 或 ≥ 18.66px 加粗) | 3 : 1 | 字大可放宽 |
| UI 组件 / 图形(边框、图标、状态) | 3 : 1 | 常被忽略,但同样要满足 |
实例:浅灰文字到底浅到哪一步就读不清
这段次要文字在白底上对比约 4.6:1,刚过正文 4.5: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 即可。
/* 语义 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↓ → 暗底不刺眼 */
}
2.6收束:颜色决策的固定顺序
把本章压成一个可执行的顺序。造色板时,从结构到点缀地走,而不是从"挑个好看的主色"开始:
- 先灰阶:用 OKLCH 固定低 C + 一个色相,阶梯式降 L,造 9 档。它撑起背景 / 边框 / 文字三层——界面的结构。
- 再语义色:固定 L 与 C、只换 H,造出"一样重"的 success / warning / error / info。
- 最后品牌强调色:1 个主色 + 它的 hover / active(只动 L),严格控制在 10% 面积内。
- 全程卡对比:任何"文字 / 背景"组合,正文 ≥ 4.5:1、大字与 UI ≥ 3:1。
- 暗色单独重绑:只改语义 token 那一层的值,降饱和、用明度表达层次。
这套顺序回扣 01 章:灰阶的三档文字 = 用明度对比建立的层级;强调色的稀缺 = "全部加强等于没有重点"在颜色上的版本。颜色从来不是装饰,它是层级的一种度量工具。
§本章 self-check
先合上教程,把答案写下来再展开对照。
- 为什么用 HSL 手调一组"看起来一样重"的语义色很难?换成 OKLCH 为什么就直接了?(答到机制,不要只说"OKLCH 更好")
- 一套 UI 调色板里占面积最大的通常是哪类颜色?为什么纯中性灰反而不如带一点点色相的灰?
- WCAG 2 的对比度公式基于什么量?它在暗色模式 / 细字重下为什么判断失准?APCA 改进了什么?
- 暗色模式为什么不能简单地把亮色模式 invert?至少说出两个具体失败,以及对应的正确做法。
答案(先做完再展开)
- 因为 HSL 的 L 是 RGB 的数学中点,不等于人眼感知亮度;同一 L、不同色相的颜色明暗参差(黄亮蓝沉),所以要"一样重"得逐个手调。OKLCH 感知均匀,固定 L 即得到一组等亮度的颜色,只换 H 即可。
- 中性灰阶(背景 / 表面 / 边框 / 三级文字),占 80%–90% 面积。纯中性灰(彩度 0)显得死板廉价;注入极低彩度 + 统一色相(冷或暖),整套灰更有质感且能与品牌色呼应。
- 基于相对亮度(relative luminance)的比值 (L1+0.05)/(L2+0.05);它不考虑字号 / 字重 / 明暗极性,对深底细字判断失准。APCA 把这些都纳入,输出更贴感知的 Lc 值(WCAG 3 草案,截至 2026 初未成正式标准)。
- 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 强调色)。