前端技术
#CSS#color-mix#色彩空间#插值#前端

CSS color-mix() 的颜色插值:为什么混合结果与预期不同

本文以主题色生成悬停态为例,解析 color-mix() 在不同色彩空间(sRGB、OKLab 等)中的插值机制,解释为何混合结果受色彩空间和插值方法影响,并与 Sass 颜色函数对比,说明浏览器支持边界与回退策略。

混合结果为何与直觉不符

假设你有一个品牌主色 #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() 的语法允许你选择矩形空间(如 srgboklab)或极坐标空间(如 hsloklch)。矩形空间直接对三个通道做线性插值;极坐标空间的色相通道是角度,需要额外的插值规则。

选择空间不是风格问题,而是数学问题。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 团队指出,hslhwb 适合“鲜艳的混合结果”,而 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%,没问题。但如果两个颜色都写了百分比且总和不足,背景会变透明,与页面背景混合,产生意料之外的视觉结果。

极坐标空间与色相插值

hslhwblchoklch 等极坐标空间中,色相是一个角度值。混合两个颜色时,需要决定色相沿哪个方向走。默认是 shorter(较短路径),但可以指定 longerincreasingdecreasing

例如,混合色相 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()
计算时机编译时运行时
默认色彩空间sRGBOKLab
可指定空间是(多种)
支持 CSS 变量
百分比规则需总和 100%可自动归一化,不足则产生透明度
色相插值控制支持 shorterlonger
浏览器支持无需浏览器支持需支持 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 的“颜色”面板,查看计算后的颜色值,并尝试切换不同的色彩空间对比。

如果发现混合结果偏灰,可以改用 oklchhsl 空间。oklch 在保持感知均匀的同时,允许控制色相和彩度。对于悬停态,通常希望保持色相不变,只调整明度,此时 oklchoklab 更合适,因为它分离了明度和彩度。

如果混合结果带透明度,检查百分比总和是否为 100%。如果使用 CSS 变量,确保变量值在 0%100% 之间。

总结

color-mix() 的混合结果由色彩空间、百分比和色相插值共同决定。默认的 OKLab 空间在多数情况下能产生感知均匀的结果,但并非所有场景都适用。理解各空间的特性,结合具体设计目标选择空间,才能避免“混合结果与预期不同”的困惑。

在悬停态生成中,建议优先使用 oklchoklab,并利用 @supports 提供回退。对于需要保持鲜艳度的场景,hslhwb 可能更合适,但需注意色相偏移。

示例与流程图

以下是一个完整的悬停态生成示例,使用 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%,则按比例缩放。

常见失败模式

  1. 默认空间误解:认为 color-mix() 默认在 sRGB 混合,导致结果偏灰。实际默认是 OKLab。
  2. 透明度陷阱:百分比总和不足 100%,产生半透明颜色,与背景混合后视觉偏差更大。
  3. 色相插值忽略:在极坐标空间混合鲜艳颜色时,未指定色相方向,默认 shorter 可能产生意外的色相路径。
  4. 浏览器兼容性:未使用 @supports 回退,导致旧浏览器无法显示悬停态。

不适用场景

color-mix() 不适用于需要精确控制混合结果且对性能敏感的场景,因为它在运行时计算,可能增加样式计算开销。对于静态颜色,Sass 预编译可能更高效。此外,color-mix() 无法处理 ICC 配置文件等自定义色彩空间,这些需要 color() 函数。

如果设计系统需要跨环境一致的颜色,建议在构建时使用 Sass 生成静态值,或使用设计令牌工具统一管理。

资料来源

  1. CSS Color Module Level 5 - W3C Working Draft
  2. color-mix() - MDN Web Docs
  3. CSS color-mix() - Chrome for Developers
  4. CSS color-mix()  |  Chrome for Developers