前端技术
#CSS#CSS Color 5#相对颜色#color-mix#设计系统

CSS 相对颜色语法如何从现有颜色派生新颜色:从颜色计算到设计系统

相对颜色语法让浏览器在运行时基于已有颜色通道计算新颜色,无需 Sass 或 JavaScript 预处理。文章以主题色生成悬停态为贯穿场景,解释 from 关键字与通道变量的处理模型,对比 color-mix() 的混合逻辑,说明与 Sass 颜色函数的差异、浏览器支持边界与回退写法。

一个按钮悬停态暴露的问题

设计系统里通常只维护一份主题色变量,按钮的悬停态、按下态、禁用态都从它派生。在 Sass 时代,这个派生动作发生在构建阶段:darken($primary, 10%) 在编译时算出一个固定的十六进制色值,写进产物 CSS。问题出现在主题色需要运行时切换时——用户在设置面板里选了一个新主色,或者页面跟随系统进入深色模式,构建阶段算好的悬停色不会跟着变。

常见的补救是用 JavaScript 读取主色、计算新色、再写回 CSS 自定义属性。这条路可行,但把颜色计算从样式层搬到了脚本层:首屏渲染前需要等脚本执行,服务端渲染的 HTML 里没有最终颜色,主题切换时还要处理一次额外的样式重算。

CSS Color Module Level 5 引入的相对颜色语法把这件事拉回样式层。它允许在颜色函数内部引用另一个颜色的通道值,浏览器在解析样式时完成计算,结果直接参与渲染,不需要脚本介入,也不需要构建期预处理。

相对颜色语法解决的具体问题

相对颜色的核心写法是在颜色函数里加一个 from 关键字,后面跟来源色,再跟输出通道的表达式:

.button {
  --brand: #3b6ef5;
  background: var(--brand);
}

.button:hover {
  background: rgb(from var(--brand) r g b / 0.85);
}

这段代码的含义是:把 --brand 当作来源色,浏览器先把它转换成 rgb() 兼容的表示,拆出红、绿、蓝三个通道值,分别绑定到 rgb 三个变量上,然后按写出的表达式组装输出色。这里 r g b 原样引用,只有 alpha 被改成 0.85,所以悬停态是主色本身降低透明度后的结果。

关键点在于:来源色可以是任何合法的 <color> 值,包括十六进制、命名色、hsl()oklch(),也包括自定义属性、currentColor,甚至另一个相对颜色。浏览器负责把它转换到目标颜色函数所需的色彩空间,开发者不需要手动换算。

资料 2 给出的例子说明了这个转换过程:rgb(from red 200 g b / alpha) 中,red 先被转换成 rgb(255 0 0),然后红通道被指定为 200,绿、蓝沿用来源值 0,alpha 沿用来源值 100%,最终得到 rgb(200 0 0)。浏览器输出的计算值是等价的 color(srgb 0.784314 0 0)

这里有一个容易忽略的细节:如果输出时没有写 alpha,它默认继承来源色的 alpha,而不是 100%。这和绝对颜色函数不同——rgb(200 0 0) 的 alpha 是 100%,而 rgb(from rgb(200 0 0 / 0.5) r g b) 的 alpha 是 0.5。在设计系统里,这意味着半透明的主色派生出的悬停态也会保持同样的透明度,除非显式覆盖。

通道变量与 calc() 的组合方式

相对颜色真正有表达力的地方,是通道变量可以参与 calc() 运算。不同颜色函数暴露的通道名不同:rgb() 暴露 rgbhsl() 暴露 hslhwb() 暴露 hwblab()oklab() 暴露 lab

用 HSL 派生悬停态是一种常见做法,因为亮度的增减比较直观:

.button:hover {
  background: hsl(from var(--brand) h s calc(l - 8));
}

资料 3 的示例把这种用法扩展到色相偏移:hsl(from var(--c) calc(h + 30) s l) 得到相邻色,calc(h + 120) 得到对比色,calc(h + 180) 得到互补色。这些偏移量是色相环上的角度,h 的单位是度数,calc() 直接做加减。

但 HSL 的亮度通道有一个已知的感知缺陷:黄色和蓝色可以有相同的 l 值,视觉亮度却差很多。资料 1 在介绍 color-mix() 的动机时明确提到这一点——HSL 色轮某些部位颜色聚集,视觉亮度差异极大的颜色可能共享同一个明度值。如果设计系统要求悬停态在感知上均匀变暗,用 HSL 的 l 做加减会在不同色相的主色上产生不一致的效果。

Oklch 是另一种选择。它的 l 通道在感知上更均匀,同样减一个数值,不同色相的主色看起来变暗的程度更接近:

.button:hover {
  background: oklch(from var(--brand) calc(l - 0.06) c h);
}

这里 l 是 0 到 1 的数值,c 是色度,h 是色相角。资料 1 的目录中列出了相对 Oklch 颜色的专门章节,说明规范对每个颜色函数的相对形式都有单独定义。

选择哪个颜色函数,取决于设计系统对“悬停态应该暗多少”的定义方式。如果定义是“亮度数值减 8”,HSL 够用;如果定义是“感知上暗一档”,Oklch 更合适。

color-mix() 与相对颜色的分工

color-mix() 和相对颜色都用于从已有颜色生成新颜色,但计算模型不同。

color-mix() 接收两个或多个颜色,在指定的色彩空间里按百分比插值,输出一个混合色。资料 1 的规范文本给出了默认行为:如果没指定插值方法,默认使用 Oklab。百分比会经过归一化,color-mix(in lch, purple 50%, plum 50%)color-mix(in lch, purple, plum) 等价。

相对颜色则是拆解单个来源色的通道,对每个通道独立赋值或计算,再重新组装。它不涉及两个颜色之间的插值。

用主题色生成悬停态时,这个差异会体现出来。如果设计意图是“主色向黑色靠拢一点”,color-mix(in oklab, var(--brand), black 15%) 表达得很直接。如果意图是“主色的亮度通道减 0.06,色相和色度不变”,相对颜色更精确,因为它明确锁定了哪些通道变化、哪些通道保持。

维度相对颜色color-mix()
输入单个来源色两个或多个颜色
计算方式拆解通道后逐通道赋值或计算在指定色彩空间按百分比插值
默认色彩空间由外层颜色函数决定Oklab
控制粒度每个通道可独立控制通过混合比例和色彩空间间接控制
适合场景亮度微调、透明度派生、色相旋转两色之间的过渡、色调调和
与 Sass 的关系替代构建期颜色函数无直接对应,Sass 无内置等价函数

这张表不是要分出优劣。实际项目中两者经常配合:用相对颜色从主色派生出基础色板,再用 color-mix() 做两个色板之间的过渡色。

与 Sass 颜色函数的差异

Sass 的 darken()lighten()saturate() 等函数在构建时求值,输出静态色值。相对颜色在浏览器解析样式时求值,输出动态色值。这个时间差带来几个实际区别。

第一是主题切换。Sass 函数的结果被写死在产物里,运行时改变 --brand 不会影响已经编译好的悬停色。相对颜色每次解析都重新读取 --brand 的当前值,主题切换时悬停态自动跟随。

第二是色彩空间。Sass 的颜色函数传统上在 sRGB 或 HSL 空间操作,资料 1 指出这类方案受限于 sRGB 色域和 HSL 的感知缺陷。相对颜色可以指定 oklch()lab() 等函数,在更广的色域和更均匀的感知空间里计算。

第三是产物体积。Sass 为每个派生色生成一条声明,主色越多、派生规则越多,产物越大。相对颜色把派生逻辑压缩成一条声明,体积不随派生数量线性增长。

代价是运行时的计算成本。浏览器需要解析来源色、转换色彩空间、执行 calc()、再组装输出。对于单个元素,这个成本可以忽略;对于包含数千个需要独立计算颜色的列表,需要关注样式解析和重算的开销。

浏览器支持与回退策略

相对颜色语法属于 CSS Color Level 5,资料 1 和资料 4 都标注为 W3C 工作草案,其中“相对 Alpha 颜色”被列为有风险功能。工作草案状态意味着规范可能变化,不同浏览器的实现进度也可能不一致。

工程上的回退策略需要分两层考虑。第一层是语法回退:不支持相对颜色的浏览器会把整条声明视为无效,回退到上一条有效声明。

.button:hover {
  background: #2f58c4; /* 静态回退色 */
  background: hsl(from var(--brand) h s calc(l - 8));
}

不支持 from 语法的浏览器忽略第二条声明,使用第一条。这种写法不需要 @supports,但要求回退色是手工维护的静态值,主题切换时不会跟随。

第二层是能力检测。@supports 可以检测相对颜色语法是否被支持:

@supports (color: rgb(from red r g b)) {
  .button:hover {
    background: rgb(from var(--brand) r g b / 0.85);
  }
}

资料 2 提到 MDN 提供了检查浏览器支持的方式。这种写法适合需要在支持和不支持时走完全不同的样式路径的场景。

回退策略的选择取决于目标浏览器范围。如果设计系统要求所有浏览器都有一致的悬停态,静态回退色是必要的;如果只要求现代浏览器有动态派生、旧浏览器有可接受的降级,语法回退就够了。

从解析到渲染的完整路径

把相对颜色放进一个具体场景:一个电商商品页,主色由运营在后台配置,页面通过 CSS 自定义属性注入。按钮、标签、价格高亮都从主色派生悬停态和浅色背景。

flowchart TD
    A[运营配置主色] --> B[注入 CSS 自定义属性 --brand]
    B --> C[浏览器解析样式规则]
    C --> D{遇到相对颜色声明}
    D --> E[读取 --brand 当前值]
    E --> F[转换到目标颜色函数色彩空间]
    F --> G[拆解为通道变量 r g b 或 h s l]
    G --> H[执行 calc 运算]
    H --> I[组装输出色]
    I --> J[参与样式计算与渲染]
    D --> K[不支持时忽略该声明]
    K --> L[使用上一条回退声明]

这条路径里有两个值得关注的转折点。一是色彩空间转换发生在通道拆解之前,来源色是什么格式不影响后续计算,但转换本身可能引入精度损失——资料 2 的例子中,red 转成 rgb(255 0 0) 是无损的,但 oklch() 来源色转成 rgb() 通道时可能落在 sRGB 色域之外。二是 calc() 的结果如果超出通道有效范围,浏览器会做钳制,比如 calc(l + 40) 在 HSL 中超过 100 会被限制到 100。

生产环境中需要观察的信号包括:主题切换后悬停态是否跟随、不同主色下悬停态的对比度是否一致、旧浏览器中回退色是否可接受。如果发现某个主色的悬停态看起来没有变化,先检查来源色是否已经被钳制到边界值——比如主色本身亮度已经接近上限,再减 8 可能仍在同一档。

适用边界与不适用场景

相对颜色适合从单个来源色派生有限数量的变体,尤其是需要运行时跟随主题变化的场景。它不适合需要精确控制两个颜色之间插值曲线的场景,那属于 color-mix() 的职责。

当来源色本身是半透明时,相对颜色的 alpha 继承行为需要特别注意。如果设计系统的主色带透明度,所有未显式指定 alpha 的派生色都会继承这个透明度,可能在叠加背景上产生意料之外的效果。

浏览器兼容性是当前最大的工程约束。资料 1 和资料 4 的规范状态都是工作草案,部分功能被标记为有风险。在要求广泛兼容的项目中,相对颜色更适合作为渐进增强层,而不是唯一的颜色派生机制。静态回退色仍然需要维护,只是维护量从“每个派生色”降低到“每个需要兼容的派生色”。

设计系统的颜色架构可以据此分层:基础色板用静态值保证兼容,运行时派生用相对颜色提供动态能力,两色过渡用 color-mix() 处理。三层各司其职,避免把一种机制用到它不擅长的边界上。

资料来源

  1. CSS Color Module Level 5 规范
  2. MDN 相对颜色
  3. CSS 相對顏色 ( Relative Colors ) - CSS 教學 | STEAM 教育學習網
  4. CSS颜色模块第5级