混合结果为何与直觉不符
假设你有一个品牌主色 #0af,想为按钮生成悬停态。最直接的想法是“把主色变深一点”,于是写下:
.button:hover {
background: color-mix(in srgb, #0af, black 25%);
}
结果却可能偏灰、偏暗,甚至带一点紫色,和设计稿里预期的“深蓝”相差甚远。问题不在颜色选错,而在 color-mix() 默认在哪个色彩空间里做插值,以及该空间的几何特性如何扭曲了中间色。
在 CSS 里,颜色不是一个固定值,而是一个可以在多个色彩空间之间转换的坐标。color-mix() 允许你指定混合发生的空间,但很多人会忽略这个参数,或者沿用 Sass 时代 darken() 的直觉。Sass 的 mix() 和 darken() 在 sRGB 通道上直接做算术平均,而 color-mix() 默认使用 OKLab——一个感知均匀的空间。两者对“深蓝”的定义完全不同。
本文以“主题色生成悬停态”为贯穿场景,分析不同色彩空间如何影响混合结果,解释百分比归一化与色相插值的规则,并给出浏览器支持边界与回退策略。
色彩空间如何影响插值
color-mix() 的语法允许你选择矩形空间(如 srgb、oklab)或极坐标空间(如 hsl、oklch)。矩形空间直接对三个通道做线性插值;极坐标空间的色相通道是角度,需要额外的插值规则。
选择空间不是风格问题,而是数学问题。sRGB 的通道值经过伽马编码,不是线性光强,也不是感知均匀的。在 sRGB 里混合蓝色 rgb(0, 0, 255) 和白色 rgb(255, 255, 255),中间点会经过 rgb(128, 128, 255),这是一个明显的紫色。这是因为蓝色通道和红色、绿色通道的插值速度相同,但人眼对蓝色的敏感度不同,导致中间色看起来偏紫。
OKLab 是 Björn Ottosson 在 2020 年提出的感知均匀空间,其坐标轴接近人眼的对立色机制。在 OKLab 中混合蓝色和白色,中间色会保持蓝调,不会经过紫色。Chrome 团队的文章明确指出,oklab 是混合(和渐变)的默认空间,因为它能提供“一致的结果”。
对于黑色和白色,不同空间的差异同样显著。srgb-linear 是线性光空间,混合黑白的中间点亮度约为 21.8%,而 sRGB 的中间点亮度约为 50%。因此 color-mix(in srgb-linear, black, white) 产生的灰色比 color-mix(in srgb, black, white) 暗得多。
悬停态生成的直观方案与失效
回到悬停态场景。传统做法是用 Sass 的 darken() 或 mix():
$brand: #0af;
.button:hover {
background: darken($brand, 10%);
}
darken() 在 HSL 空间里降低亮度通道,但 HSL 的亮度不是感知均匀的。降低 10% 亮度可能让颜色变得浑浊,尤其在蓝色系上。Sass 的 mix($brand, black, 25%) 则在 sRGB 通道上做加权平均,结果与 color-mix(in srgb, $brand, black 25%) 类似,都可能偏灰。
color-mix() 的出现让开发者可以在浏览器里直接混合颜色,但如果不指定空间,默认是 oklab。在 OKLab 中混合主色和黑色,中间色会保持色相稳定,但饱和度可能下降。对于 #0af(一种青色),混合 25% 黑色后,得到的颜色比 sRGB 混合更暗、更饱和,更接近设计师期望的“深蓝”。
然而,OKLab 并非万能。如果品牌色是鲜艳的洋红色,在 OKLab 中混合黑色会迅速降低饱和度,产生灰暗的紫红色;而在 hsl 中混合则能保持鲜艳度,但色相可能偏移。Chrome 团队指出,hsl 和 hwb 适合“鲜艳的混合结果”,而 oklab 适合“一致性和细腻度”。
百分比归一化与透明度陷阱
color-mix() 的百分比规则比想象中复杂。规范规定:如果两个百分比都省略,各占 50%;如果只提供一个,另一个自动补足剩余;如果总和超过 100%,则按比例缩放回 100%。
例如 color-mix(in lch, purple 80%, plum 80%),总和 160%,会被缩放为各 50%。这个行为与 Sass 的 mix() 不同——Sass 要求两个权重相加为 100%,否则会报错或按比例缩放。
更隐蔽的是,如果百分比总和小于 100%,结果会带有透明度。color-mix(in lch, purple 20%, plum 20%) 会产生一个 40% 透明度的颜色。这在生成悬停态时很容易被忽略:如果你写 color-mix(in oklab, var(--brand) 30%, black),黑色没有百分比,自动补足 70%,总和 100%,没问题。但如果两个颜色都写了百分比且总和不足,背景会变透明,与页面背景混合,产生意料之外的视觉结果。
极坐标空间与色相插值
在 hsl、hwb、lch、oklch 等极坐标空间中,色相是一个角度值。混合两个颜色时,需要决定色相沿哪个方向走。默认是 shorter(较短路径),但可以指定 longer、increasing 或 decreasing。
例如,混合色相 10° 和 350° 的两个颜色,shorter 会走 20° 的短弧,而 longer 会走 340° 的长弧。这直接影响中间色的色相。
在悬停态场景中,如果主色和黑色混合,黑色没有色相,色相插值不会造成问题。但如果混合两个彩色(例如品牌色和辅助色),色相方向就至关重要。Chrome 文档警告:为非极坐标空间指定色相插值方法是语法错误。例如 color-mix(in srgb shorter, red, blue) 是无效的。
与 Sass 颜色函数的对比
Sass 的 mix() 和 darken() 是构建悬停态的常用工具,但它们与 color-mix() 有本质区别。Sass 在编译时计算颜色,输出固定的十六进制值;color-mix() 在浏览器运行时计算,可以接受 CSS 变量。
更重要的是,Sass 的 mix() 默认在 sRGB 空间操作,没有选择空间的选项。color-mix() 则允许你指定任意空间,包括感知均匀的 OKLab 和线性光的 srgb-linear。下表总结了关键差异:
| 特性 | Sass mix() | CSS color-mix() |
|---|---|---|
| 计算时机 | 编译时 | 运行时 |
| 默认色彩空间 | sRGB | OKLab |
| 可指定空间 | 否 | 是(多种) |
| 支持 CSS 变量 | 否 | 是 |
| 百分比规则 | 需总和 100% | 可自动归一化,不足则产生透明度 |
| 色相插值控制 | 无 | 支持 shorter、longer 等 |
| 浏览器支持 | 无需浏览器支持 | 需支持 color-mix() 的浏览器 |
浏览器支持与回退策略
color-mix() 自 2023 年 5 月起在主流浏览器中可用,MDN 将其标记为“Baseline Widely Available”。但需要注意,部分功能(如 oklch 空间)可能依赖较新的浏览器版本。
对于不支持 color-mix() 的浏览器,可以使用 @supports 提供回退:
.button {
background: #0af; /* 基础色 */
}
@supports (background: color-mix(in oklab, red, blue)) {
.button:hover {
background: color-mix(in oklab, var(--brand), black 25%);
}
}
如果使用 Sass 预处理器,可以在编译时生成静态颜色作为回退,但这样会失去运行时变量的灵活性。另一种策略是使用 CSS 自定义属性配合 color-mix(),让不支持的环境直接使用自定义属性的后备值。
生产环境中的诊断与建议
在真实项目中,悬停态的颜色偏差往往在视觉审查时才被发现。要定位问题,可以打开 DevTools 的“颜色”面板,查看计算后的颜色值,并尝试切换不同的色彩空间对比。
如果发现混合结果偏灰,可以改用 oklch 或 hsl 空间。oklch 在保持感知均匀的同时,允许控制色相和彩度。对于悬停态,通常希望保持色相不变,只调整明度,此时 oklch 比 oklab 更合适,因为它分离了明度和彩度。
如果混合结果带透明度,检查百分比总和是否为 100%。如果使用 CSS 变量,确保变量值在 0% 到 100% 之间。
总结
color-mix() 的混合结果由色彩空间、百分比和色相插值共同决定。默认的 OKLab 空间在多数情况下能产生感知均匀的结果,但并非所有场景都适用。理解各空间的特性,结合具体设计目标选择空间,才能避免“混合结果与预期不同”的困惑。
在悬停态生成中,建议优先使用 oklch 或 oklab,并利用 @supports 提供回退。对于需要保持鲜艳度的场景,hsl 或 hwb 可能更合适,但需注意色相偏移。
示例与流程图
以下是一个完整的悬停态生成示例,使用 CSS 自定义属性:
:root {
--brand: #0af;
--brand-hover: color-mix(in oklch, var(--brand), black 20%);
}
.button {
background: var(--brand);
}
.button:hover {
background: var(--brand-hover);
}
如果浏览器不支持 color-mix(),var(--brand-hover) 会失效,此时需要回退:
.button:hover {
background: #08c; /* 手动计算的深蓝色 */
}
@supports (background: color-mix(in oklch, red, black)) {
.button:hover {
background: var(--brand-hover);
}
}
下图展示了 color-mix() 的计算流程:
flowchart TD
A[输入颜色1] --> C{指定色彩空间?}
B[输入颜色2] --> C
C -->|是| D[转换到指定空间]
C -->|否| E[默认使用 OKLab]
D --> F[百分比归一化]
E --> F
F --> G{总和=100%?}
G -->|是| H[按权重插值]
G -->|否| I[缩放或产生透明度]
H --> J[输出颜色]
I --> J
流程图中,关键转折点是百分比归一化:如果总和小于 100%,结果会带透明度;如果大于 100%,则按比例缩放。
常见失败模式
- 默认空间误解:认为
color-mix()默认在 sRGB 混合,导致结果偏灰。实际默认是 OKLab。 - 透明度陷阱:百分比总和不足 100%,产生半透明颜色,与背景混合后视觉偏差更大。
- 色相插值忽略:在极坐标空间混合鲜艳颜色时,未指定色相方向,默认
shorter可能产生意外的色相路径。 - 浏览器兼容性:未使用
@supports回退,导致旧浏览器无法显示悬停态。
不适用场景
color-mix() 不适用于需要精确控制混合结果且对性能敏感的场景,因为它在运行时计算,可能增加样式计算开销。对于静态颜色,Sass 预编译可能更高效。此外,color-mix() 无法处理 ICC 配置文件等自定义色彩空间,这些需要 color() 函数。
如果设计系统需要跨环境一致的颜色,建议在构建时使用 Sass 生成静态值,或使用设计令牌工具统一管理。