配了八年设计系统,今天才发现改一个主色所有 token 都要重算——CSS relative color syntax 把这件事彻底原生化了

每个设计系统里,主色 token 只有一个,但它的派生 token 有 6 到 10 个:hover、active、pressed、light、dark、muted、surface、overlay、disabled、focus ring。改主色时要么手动重算每一个,要么靠 Sass 编译时生成——但 Sass 在运行时改不了主题色。这是过去十年设计系统的日常,今天这件事被 CSS Color Level 5 的相对颜色语法彻底原生化了。

从 Sass 到纯 CSS:oklch(from var(…)) 替代手算

过去你大概这么写 token:

:root {
  --brand: #3b82f6;
  --brand-hover: lighten(#3b82f6, 12%);  /* Sass */
  --brand-active: darken(#3b82f6, 8%);
  --brand-muted: #93c5fd;
}

Sass 的 lighten()darken() 在编译时跑,跑完输出静态 hex 值。如果你在运行时切换主题色(比如 prefers-color-scheme 或用户手动切换),Sass 生成的这些派生值不会更新——因为它们已经「烧死」在 CSS 里了。

相对颜色语法把这整套逻辑搬进了浏览器,而且是实时的:

:root {
  --brand: oklch(65% 0.25 260);
}
.btn {
  background: var(--brand);
}
.btn:hover {
  background: oklch(from var(--brand) calc(l + 0.12) c h);
}
.btn:active {
  background: oklch(from var(--brand) calc(l - 0.1) c h);
}

from 关键字把 --brand 解构成 oklch 的三个通道:l(亮度)、c(色度)、h(色相),然后用 calc() 做数学,返回一个新颜色。只改 --brand 这一行,所有派生色自动更新,不需要任何构建工具。

oklch 为什么比 HSL 更适合做这件事

HSL 的 L 值不是感知均匀的——HSL 里把一个蓝色和一个黄色的 L 值同时加 10%,人眼看到的亮度变化幅度完全不同。而 oklch 是感知均匀的颜色空间,oklch 里的 L 是心理物理亮度,HSL 的 L 是「三轮车」级别的近似。

用 oklch 做派生,步进感是一致的:

/* HSL:同一个 +10% 在不同色相下视觉步进差很多 */
/* oklch:+0.1 在任何色相下看起来都是同样的亮度变化 */

.card {
  background: oklch(from var(--brand) calc(l + 0.15) c h);  /* 浅 */
}
.card:hover {
  background: oklch(from var(--brand) calc(l + 0.08) c h);  /* 略浅 */
}
.card:disabled {
  background: oklch(from var(--brand) l calc(c * 0.3) h);   /* 低饱和 */
}

五个实战用法

hover / active 状态

.btn {
  background: var(--brand);
  color: oklch(from var(--brand) calc(l + 0.55) c h);  /* 白色文字 */
}
.btn:hover {
  background: oklch(from var(--brand) calc(l + 0.08) c h);
}
.btn:active {
  background: oklch(from var(--brand) calc(l - 0.1) c h);
}

语义色表面板(透明叠加)

.alert-info {
  background: oklch(from var(--brand) l c h / 12%);
}
.badge {
  border: 1px solid oklch(from var(--brand) calc(l - 0.15) c h);
}

派生色系(互补色、低饱和、色相差)

.badge-complement {
  color: oklch(from var(--brand) l c calc(h + 180));  /* 互补色 */
}
.badge-muted {
  color: oklch(from var(--brand) l calc(c * 0.3) h);  /* 低饱和 */
}
.badge-tinted {
  color: oklch(from var(--brand) calc(l + 0.45) calc(c * 0.6) h); /* 极浅 */
}

暗色主题

@media (prefers-color-scheme: dark) {
  .btn {
    background: oklch(from var(--brand) calc(100 - l) c h);
  }
}

只翻转亮度,色相和色度完全保留。改一行主色,深浅两套主题自动同步。

配合 color-mix() 混合白或黑

.surface-tinted {
  background: color-mix(in oklch, var(--brand) 8%, var(--surface));
}
.focus-ring {
  outline-color: color-mix(in oklch, var(--brand) 55%, transparent);
}

哪些通道可以用

不同颜色函数暴露不同通道:

函数 可用通道
oklch() l, c, h, alpha
hsl() h, s, l, alpha
rgb() r, g, b, alpha
color() r, g, b(RGB 空间)或 x, y, z(XYZ 空间)

你甚至可以跨颜色空间转换:

/* 取 oklch 主色的色相,配上新的亮度和色度,输出 RGB */
color: color(from var(--brand) srgb calc(l * 1.2) calc(c * 0.8) calc(h + 20));

三个坑

1. Safari 16.2 以下不认识 relative color syntax。 必须做渐进增强:

.btn {
  background: #3b82f6;  /* fallback */
  background: oklch(from var(--brand) calc(l + 0.08) c h);  /* 现代浏览器 */
}
@supports (color: oklch(from red l c h)) {
  .btn { background: oklch(from var(--brand) calc(l + 0.08) c h); }
}

2. 通道值是数字,不是百分比。calc(l + 10%) 会让浏览器直接忽略这行,必须写 calc(l + 0.1)

3. oklch 可以描述超出 sRGB 色域的颜色。 浏览器会自动做 gamut mapping,但如果目标是 Display P3,记得加 @media (color-gamut: p3) 覆盖,或者直接用 color(display-p3 ...) 做主色。

浏览器支持

Chrome 119+、Firefox 128+、Safari 16.4+、Edge 119+ 支持相对颜色语法。全球覆盖率约 89.6%,所有常青浏览器均已覆盖,生产可用,不需要 polyfill。

三步下一步

第一步: 查你现在的主色 token 派生出了几个子 token——hover、light、dark、muted……数一数有没有 6 个以上。如果有,这篇文章说的就是你的场景。

第二步: 把主色 token 改成 oklch 格式。Chrome DevTools 的颜色选择器可以直接显示 oklch 值,或者用 online converter 把 hex 转 oklch。

第三步: 把派生 token 替换成 oklch(from var(--brand) calc(l ± delta) c h)。删掉 Sass 依赖,删掉手写的 hex派生值。改一个主色,整个色板同步更新。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 15 阅读