调色板上那个蓝色被改到第八遍的时候,我才真正意识到,CSS颜色表示方法这件事,远不是记住几个函数名那么简单。同一个视觉稿,用十六进制写和用 oklch 写,后期维护的工作量能差出两倍;同一个半透明遮罩,用transparent和用rgba(255,255,255,0)做渐变,出来的效果能差一个色阶。这些坑都藏在语法细节里,文档不会提醒你,只有改稿改到深夜的人才知道疼。
这篇东西想聊的就是这一层:CSS颜色表示方法到底有哪几套体系,各自的适用边界在哪里,怎么把一堆散落的色值整理成能长期维护的颜色令牌,以及那些年我在颜色上踩过的具体坑。不管你是刚写完第一个静态页、还在纠结#fff和rgb(255,255,255)有什么区别,还是已经带团队做设计系统、被色阶一致性折磨过,下面的内容应该都能直接拿去用。我会尽量把每个选择背后的“为什么”讲透,参数和数值也给到可以直接抄的程度。
1. 颜色体系的整体设计与选型思路
1.1 为什么表示法会有这么多套
要理解颜色表示法的分裂,得先明白它们解决的问题不一样。CSS 颜色的底层模型是 sRGB 三通道,红绿蓝各占一个字节,取值 0 到 255。十六进制写法本质上就是这个字节序列的紧凑编码——两位十六进制刚好表示一个 0 到 255 的数,所以#ff0000就是“红 255、绿 0、蓝 0”。它短、好粘贴、几乎零解析成本,这是它统治了二十年的原因。
但通道思维有个致命弱点:人脑不按 RGB 思考。设计师说“这个蓝再亮一点、再淡一点”,你面对#3b82f6根本不知道该动哪个通道。于是有了hsl(),把颜色拆成色相、饱和度、亮度三个维度,色相是个圆环,调亮就动 L,调淡就动 S,符合直觉。问题在于,hsl 的 L 是数学亮度,不是感知亮度——同样 50% 的 L,黄色看着刺眼,蓝色看着发闷。这就是为什么后来出现了 oklch 这类基于感知均匀色彩空间的表示法。
再往后是广色域。sRGB 只覆盖了人眼可见色域的一部分,显示器越做越好之后,color(display-p3 ...)这类写法才有了实际意义。所以这条演进线的逻辑很清楚:从“机器好算”到“人好调”,再到“色域够宽、感知够准”。选型的时候,你其实是在选自己更看重哪一头。
1.2 选型时真正该看的三个维度
我在项目里判断用哪种表示法,基本看三件事。第一是是否需要程序化生成。如果颜色是从品牌主色派生出来的,需要批量算色阶、算 hover 态、算半透明遮罩,那 oklch 或者 hsl 这类可计算的形式优势巨大;纯手工写死的静态色值,十六进制最省事。
第二是是否需要跨工具流转。设计工具导出、后端配置下发、第三方 SDK 传参,这些场景里十六进制和 rgb 的兼容性最好,不会被奇怪的解析规则绊住。oklch 虽然现代浏览器都认,但如果色值要落到一个老旧的图表库配置里,可能直接报错。
第三是团队的平均熟悉度。这点经常被忽略。一套再先进的颜色体系,如果团队里一半人看不懂,最后还是会退化成“复制粘贴十六进制”。我的做法是内部统一用一种主表示法,其他形式只在必要时作为派生手段出现,别让同一个项目里三种写法混着跑。
注意:同一个项目里混用多种颜色表示法本身不是错,错在没有约定。至少要有一条规则写进规范:手写静态色值用十六进制,派生色值用颜色函数,禁止在同一层级混着来。
1.3 一个常见的架构误区
很多人一开始就把颜色变量按组件命名,比如--button-bg、--card-border、--header-text。短期看很直观,项目一变大就崩了——换个主题要改几十个变量,加个新组件又得新增一批,最后变量表比业务代码还长。
正确做法是分层。底层是原始色板,只描述颜色本身,不带任何用途语义;中间层是语义令牌,描述“这个颜色用来干什么”,比如背景、文字、边框、危险态;组件层尽量不定义颜色,只引用语义令牌。这样换肤只需要动中间层,组件层完全不用碰。这个结构在后面第 3 节会展开讲,先把思路立住。
2. 各类表示法的核心细节拆解
2.1 十六进制:最短,但透明度最坑
十六进制的四种形态得记清楚:#RGB、#RGBA、#RRGGBB、#RRGGBBAA。三位和四位的短写规则是每位重复一次,#f00等于#ff0000,#f008等于#ff000088——注意是8变成88,不是补零。
真正容易出错的是八位写法里 alpha 的位置和进制。#RRGGBBAA的 alpha 在最后两位,而且是十六进制,范围00到FF,对应 0 到 255,再除以 255 才是最终的透明度。而rgba()里 alpha 是 0 到 1 的小数。这两套单位混着用,是新人最容易翻车的地方。
我把常用的透明度换算整理成了表,直接查比心算靠谱:
| 不透明度 | rgba alpha | 八位十六进制后缀 |
|---|---|---|
| 100% | 1 | FF |
| 90% | 0.9 | E6 |
| 80% | 0.8 | CC |
| 70% | 0.7 | B3 |
| 60% | 0.6 | 99 |
| 50% | 0.5 | 80 |
| 40% | 0.4 | 66 |
| 30% | 0.3 | 4D |
| 20% | 0.2 | 33 |
| 10% | 0.1 | 1A |
| 5% | 0.05 | 0D |
记住几个高频值就够了:80约等于一半,33约等于两成,1A约等于一成。设计稿里那种“黑色 8% 的投影”,落到代码就是#00000014(20 除以 255 约等于 0.078),或者直接写rgb(0 0 0 / 0.08),后者可读性明显更好,我个人现在更倾向后者。
2.2 rgb() 与空格新语法:别把两套语法搅在一起
rgb()有两个时代。老语法是逗号分隔,rgb(255, 0, 0),带透明度必须换名字写rgba(255, 0, 0, 0.5)。新语法是空格分隔加斜杠,rgb(255 0 0 / 0.5),透明度直接挂在后面,函数名统一用rgb()就行,rgba()反而成了历史遗留。
关键禁忌:两套语法不能混用。rgb(255, 0, 0 / 0.5)这种逗号和斜杠混着写的,整条声明会被丢弃,控制台也不会给你明显提示,非常难查。我见过不止一个人在这上面耗掉半小时。
另一个细节是单位。每个通道既可以是 0 到 255 的数字,也可以是百分比,rgb(100% 0 0)和rgb(255 0 0)等价。虽然规范允许各通道独立选择单位,但为了不给自己挖坑,我的建议是整个项目统一一种,别一会儿数字一会儿百分比。
新语法真正的价值在于它让颜色可以参与计算。配合 CSS 变量和calc(),你能写出rgb(from var(--brand) r g b / 0.6)这种相对颜色表达式,直接从主色派生出六成透明度的版本,改主色的时候所有派生色自动跟着变。这个能力放在三年前还得靠预处理器,现在原生就支持了。
2.3 hsl():调色最顺手,但亮度会骗人
hsl()的三个参数是色相、饱和度、亮度。色相是个 0 到 360 的圆环,0 是红、120 是绿、240 是蓝,单位可以是deg,也可以省略,还能用turn(一圈)和rad。饱和度和亮度都是百分比,hsl(210 60% 45%)读起来就是“蓝偏青、中等饱和、偏暗”。
它的最大优势是心智模型贴近设计师的语言。“色相不变,亮度提 10%”对应hsl(210 60% 55%),一行就改完了,用十六进制你得重新算三个通道。所以做色阶、做状态色变体的时候,hsl 比十六进制高效得多。
但它有个真实的缺陷:感知亮度不均匀。同样是L = 50%,hsl(60 100% 50%)是刺眼的亮黄,hsl(240 100% 50%)是深蓝,两者在白底上的对比度差了将近三倍。如果你的色阶是“每个色相固定 50% 亮度作为主色”,做出来的界面会明显失衡——黄色按钮跳出来,蓝色按钮缩回去。
我早期的做法是用 hsl 生成色阶,然后手工微调每个色相的 L 值去补偿,工作量大且不可复制。后来换成 oklch 就彻底解决了,下面细说。
2.4 oklch 与 oklab:感知均匀带来的质变
oklch 的三个参数是 L(感知亮度,0 到 1 或 0% 到 100%)、C(色度,0 到大约 0.4)、H(色相角)。oklab 则是 L、a、b 三个笛卡尔坐标。两者是同一个色彩空间的两种表达,oklch 用极坐标,更符合调色直觉。
它解决的核心问题就是上面说的感知均匀:同样的 L 值,不同色相看起来亮度基本一致。这意味着你可以用一套固定的 L 阶梯,比如 0.95、0.88、0.80、0.70、0.60、0.52、0.44、0.36、0.28,套到任意色相上生成色阶,出来的深浅节奏是统一的。这对设计系统来说是质变级别的提升,色板从“每个色相手工调”变成“一个函数批量生成”。
需要注意 C 的上限不是固定的。在 sRGB 色域内,能表示的最大色度会随 L 和 H 变化:L 接近 0 或 1 的时候,C 必须收得很小,否则会被裁切;某些色相(比如青绿区间)能承受的 C 比其他色相高。所以生成色阶时不能所有档位都用同一个 C,得让它随 L 递减。一个可用的经验配方是:中间档位 C 最大,往两端线性收窄。
举个实际的例子,同一个蓝色系,H 固定 250:
:root { --blue-50: oklch(0.97 0.02 250); --blue-100: oklch(0.94 0.04 250); --blue-200: oklch(0.88 0.07 250); --blue-300: oklch(0.80 0.10 250); --blue-400: oklch(0.70 0.13 250); --blue-500: oklch(0.60 0.15 250); --blue-600: oklch(0.52 0.14 250); --blue-700: oklch(0.44 0.12 250); --blue-800: oklch(0.36 0.09 250); --blue-900: oklch(0.28 0.06 250); }这套值我在几个项目里用过,视觉上的台阶感很均匀,不像 hsl 那样中间几档挤在一起。要换色相做一整套品牌色,只改 H 就行,L 和 C 完全不动,出来的一致性非常好。
提示:oklch 的 L 用 0 到 1 的小数和 0% 到 100% 的百分比都合法,但同一个文件里统一一种,混着写以后搜索替换会很难受。
2.5 关键字、transparent 与 currentColor
CSS 有一百多个命名颜色,rebeccapurple、tomato、goldenrod这些名字很好记,适合做原型和示例。但正式项目里我基本不用,原因很简单:名字和品牌色板对不上,搜代码的时候也搜不出规律。唯一的例外是white和black,肉眼可读性确实比#fff好一些。
transparent值得单独说,因为它等于rgb(0 0 0 / 0),是透明黑,不是“无色”。这个定义在渐变里会咬人:从红色渐变到transparent,中间会经过一层灰暗的过渡,因为插值是在预乘空间里做的,透明黑的 RGB 分量还是 0。正确写法是linear-gradient(to right, red, rgb(255 0 0 / 0)),让透明端保持红色分量,过渡就干净了。这个坑我至少踩过三次,每次都要重新想一遍才明白。
currentColor是另一个高频工具,它取的是元素自身color属性的计算值。用在border-color、box-shadow、SVG 的fill上非常方便——把按钮的color设为白色,边框自动变白,不用重复写色值。SVG 图标用fill="currentColor"就能跟着文字色走,一套图标同时适配明暗主题,比导出两套图省事太多。
3. 把颜色变成可维护的令牌体系
3.1 三层结构怎么切分
第一层是原始色板,命名只描述颜色本身:--blue-500、--gray-100、--red-600。这一层不关心用在哪儿,只保证色阶本身是均匀的。
第二层是语义令牌,命名描述用途:--color-bg-surface、--color-text-primary、--color-border-subtle、--color-danger。这一层的值指向第一层,比如--color-danger: var(--red-600)。
第三层是主题覆盖,在[data-theme="dark"]里重新赋值语义令牌:--color-bg-surface: var(--gray-900)。组件层只写color: var(--color-text-primary),永远不直接引用原始色板。
这个分层的收益在切主题那天最明显。我用这套结构做过一个后台系统,暗色主题上线只写了四十来行覆盖代码,组件一行没改。反过来,如果组件里直接写死十六进制,那基本等于重写一遍样式。
3.2 半透明叠加要按层算,不能凑数
半透明颜色有个反直觉的特性:它叠在什么颜色上,结果就不同。一个 10% 的黑色遮罩压在白底上是浅灰,压在深灰底上几乎看不出来。所以做悬浮态、遮罩层、分割线的时候,不能拍脑袋定一个固定值,得考虑它实际所处的那一层背景。
我的处理办法是统一用“叠一层透明度”代替“算一个实色”。悬停态不要写死#e5e7eb,而是写rgb(0 0 0 / 0.06)叠在基础背景上;不同背景色下自动呈现不同的视觉效果,反而更自然。同时这也解决了一个老问题:主题切换时不用为每个背景补一套悬停色,透明度会自动适配。
多层叠加还要注意累加效应。卡片本身有 5% 的遮罩,里面再放一个 5% 的遮罩,视觉上会变成 9.75%,不是 10%。如果嵌套层级深,容易叠出一块脏兮兮的暗斑。排查这类问题的诀窍是临时把某一层的背景设成纯色,一层一层加回去,看是哪一层让颜色跑偏了。
3.3 交互态用相对颜色派生
如果浏览器支持相对颜色语法,交互态可以直接从主色算出来,不用维护一堆手写的深色变体:
.button { --btn-bg: oklch(0.60 0.15 250); background: var(--btn-bg); } .button:hover { background: oklch(from var(--btn-bg) calc(l + 0.06) c h); } .button:active { background: oklch(from var(--btn-bg) calc(l - 0.05) c h); }这段逻辑的妙处在于可复用。按钮换成红色,hover 和 active 自动跟着变;要统一调整所有按钮的悬停幅度,只改一处calc的量。相比给每个颜色手写三个变体,维护成本降了一个数量级。
同一套语法也能做透明度派生,rgb(from var(--brand) r g b / 0.12)能得到主色的 12% 版本,用来做浅色背景块、标签底色、聚焦光环都很顺手,而且永远和主色保持色相一致。
3.4 明暗主题的两种实现路径
一条路是>:root { color-scheme: light dark; --color-bg: light-dark(#ffffff, #0f1115); --color-text: light-dark(#1a1a1a, #e8e8e8); }
它依赖color-scheme声明,并且跟随系统偏好自动切换。适合“跟随系统”这种简单场景;如果要做手动切换开关,>.hero { background: linear-gradient(in oklch, oklch(0.6 0.18 250), oklch(0.75 0.16 90)); }
指定in oklch之后,过渡会沿着感知均匀的路径走,中段保持色度,不会塌成灰色。同理还有in oklab和in hsl。做品牌渐变、图表配色、大面积背景的时候,切换插值空间带来的提升是最容易被感知到的。
有个取舍要注意:oklch 插值在经过色相圆环时可能绕路。比如从 H=10 到 H=350,短路径是往回走 20 度,但某些实现会绕一整圈,导致彩虹效果。遇到这种情况,改用in oklab(笛卡尔坐标,不存在绕圈问题)通常更稳。
4.3 阴影和边框的色相选择
纯黑阴影是最常见的偷懒做法,效果也最“脏”。真实世界的阴影会带上环境色的补色倾向,所以专业做法是给阴影染一点主色调,或者用蓝紫调的深色代替纯黑。一个可用的模板:
.card { box-shadow: 0 1px 2px oklch(0.25 0.03 260 / 0.08), 0 8px 24px oklch(0.25 0.03 260 / 0.10); }两层阴影,一层贴边做硬边,一层扩散做柔和,色相统一在蓝紫区间,透明度靠 alpha 控制。这比0 2px 8px rgba(0,0,0,0.15)出来的质感高一截,而且换主题的时候阴影不会显得突兀。
边框也是同理。纯黑或纯灰的 1px 边框在浅色背景上会显得生硬,用主色低透明度或者带一点色相的灰,整体会更协调。oklch(0.5 0.02 250 / 0.18)这种写法出来的边框,比#e0e0e0耐看。
4.4 一套可以直接用的变量骨架
:root { color-scheme: light dark; /* 第一层:原始色板 */ --blue-500: oklch(0.60 0.15 250); --blue-600: oklch(0.52 0.14 250); --gray-50: oklch(0.98 0.00 250); --gray-900: oklch(0.20 0.01 250); /* 第二层:语义令牌 */ --color-bg-base: light-dark(var(--gray-50), var(--gray-900)); --color-brand: var(--blue-500); --color-brand-hover: oklch(from var(--color-brand) calc(l + 0.06) c h); --color-text-primary: light-dark(var(--gray-900), var(--gray-50)); --color-overlay: rgb(0 0 0 / 0.06); }结构不复杂,但把“色值来源”和“使用语义”彻底分开了。后面加新主题、调品牌色、做 A/B 配色,改动都集中在这一段里。
5. 常见问题与排查实录
5.1 症状与原因的速查表
| 症状 | 大概率原因 | 处理办法 |
|---|---|---|
| 颜色声明完全不生效 | 逗号与斜杠混用,如rgb(1, 2, 3 / .5) | 统一改成rgb(1 2 3 / 0.5) |
| 半透明遮罩偏黑偏脏 | 用了transparent做渐变端点 | 换成同色相的零透明度写法 |
| 暗色切换时闪白 | 主题属性在脚本执行后才设置 | 在<head>内联脚本提前打标 |
| 变量在子元素上失效 | 变量定义在某个局部选择器里 | 提到:root或组件根节点 |
| 渐变中段发灰 | sRGB 空间插值 | 加in oklch或in oklab |
| 图标颜色不跟随文字 | SVG 里写死了fill | 改成fill="currentColor" |
| 打印时背景色丢失 | 浏览器默认不打印背景 | 加print-color-adjust: exact |
| 透明叠加后颜色偏深 | 多层 alpha 累加 | 改为单层表达或降低单层值 |
5.2 几条踩出来的经验
第一条,调试颜色先看计算值。开发者工具的样式面板里,把鼠标停在颜色小方块上会显示最终计算出的颜色和格式。很多时候你以为是语法写错,其实是被更高优先级的选择器覆盖了。养成先确认“哪条声明在生效”的习惯,比反复改语法高效得多。
第二条,别在 CSS 变量里存“半成品”颜色。--brand-rgb: 59, 130, 246这种写法在老项目里很常见,用的时候拼成rgba(var(--brand-rgb), 0.5)。问题是它锁死了格式,想换成 oklch 就得全项目改。现在有相对颜色语法,直接存完整颜色值,用的时候派生,灵活得多。
第三条,色彩对比度要按实际叠加后的结果算。文字压在图片上、压在渐变上、压在两层半透明遮罩上,最终呈现的颜色和你写的色值不是一回事。这类地方我一般会加一层足够不透明的底色做保底,宁可损失一点通透感,也要保证可读性。
第四条,色板数量要克制。一开始很容易堆出几十个颜色变量,觉得“以后可能用得上”。实际情况是,超过二十个语义色之后,团队就开始记不住哪个该用在哪里了。我的经验是主色三到五个色阶、灰阶七到九个、语义色不超过八个,基本能覆盖九成场景。
6. 兼容性判断与降级写法
6.1 双声明的顺序不能反
浏览器遇到不认识的属性值时,会整条声明丢弃。利用这个特性可以做天然降级:先写兼容性好的值,再写新语法,老浏览器忽略后一条,新浏览器用后一条覆盖。
.badge { background: #3b82f6; background: oklch(0.60 0.15 250); color: #ffffff; color: oklch(1 0 0); }顺序反了就没意义——新浏览器先应用了旧值,再用新值覆盖;如果新值写在前面,老浏览器丢弃它之后就没有任何背景色了。
6.2 变量场景要用 @supports
上面这招在 CSS 变量上不管用。变量存的是字符串,解析发生在使用处,写两条--x声明只会让后一条覆盖前一条,跟浏览器支持与否无关。这种情况必须用特性检测:
:root { --color-brand: #3b82f6; } @supports (color: oklch(0 0 0)) { :root { --color-brand: oklch(0.60 0.15 250); } }@supports的条件写一个该语法的最小合法示例就行,别写复杂表达式,容易因为解析歧义导致判断失准。
6.3 广色域要谨慎打开
color(display-p3 ...)能在支持广色域的屏幕上显示更饱和的颜色,视觉效果确实更抓眼。但它的问题是没有色彩管理:同一个色值在广色域屏上鲜艳,在普通屏上会被映射成一个偏暗偏灰的近似色,两端的观感差异可能超出预期。
我的做法是只在装饰性元素上用广色域色值,比如大面积的插画背景、渐变光效;涉及文字、图标、状态指示的地方一律留在 sRGB 内,保证所有设备上传达的信息一致。真要上广色域,先在不支持的老设备和普通色域屏上各看一遍,确认降级结果能接受再发布。
写到这里,我自己的习惯已经比较固定了:手写静态色值用十六进制图个省事,凡是需要派生、需要换主题、需要批量生成的地方,一律走 oklch 加相对颜色。这套组合用了两年多,改稿频率明显降下来了——以前换个品牌色要清一遍全局替换,现在只改三五个变量就能收工。真要说还有什么没解决的问题,大概就是设计工具那边的色值导出还是十六进制,每次都得手动转一道,这个环节目前还得靠人盯着。