写过 CSS 的人都踩过这个坑——暗色模式每个颜色要写两遍,今天 CSS 一个函数把这个硬骨头彻底原生化了

每次接手一个有暗色模式的项目,打开 CSS 都会看到同一套三件套:一个 :root 块声明所有颜色变量,一个 @media (prefers-color-scheme: dark) 块把所有颜色重写一遍,然后底部再叠一个 JavaScript 的 data-theme 切换。每个颜色 token 要写两次,每次加新颜色都要在两个地方改,改着改着两边就对不上了。

CSS light-dark() 函数解决的就是这个问题。它接受两个颜色,返回值根据用户当前配色方案自动选——第一个是亮色,第二个是暗色。核心不是它「加」了什么,而是它让你能删掉多少代码。

light-dark() 怎么用

先说一件所有教程都藏着不重点说的事:light-dark() 静默失效不报错。如果你发现暗色不生效,99% 的原因是父元素没有声明 color-scheme: light dark。浏览器没有这个声明就不知道该听谁的,直接返回第一个值然后一声不吭。

所以在任何暗色主题页面里,第一行永远是:

:root {
  color-scheme: light dark;
}

有了这个声明,light-dark() 才真正工作:

body {
  background: light-dark(#fafafa, #0f0f0f);
  color: light-dark(#1a1a1a, #f4f4f5);
}

color-scheme 同时让浏览器把原生控件(滚动条、表单、文件选择器)自动切成暗色,不需要额外写一行。这个属性 2026 年全球覆盖率 ~96%,所有主流浏览器全部支持。

删掉重复的 :root 变量

大多数设计系统的暗色模式是这样的:

:root {
  --color-bg: #fafafa;
  --color-surface: #ffffff;
  --color-text: #1a1a1a;
  --color-text-muted: #71717a;
  --color-border: #e5e5e5;
  --color-accent: #4ade80;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0b0b0d;
    --color-surface: #16161a;
    --color-text: #f4f4f5;
    --color-text-muted: #a1a1aa;
    --color-border: #2a2a2e;
    --color-accent: #22c55e;
  }
}

18 行,9 行做实际工作,另外 9 行在重复同一个 schema。light-dark() 让这个变成:

:root {
  color-scheme: light dark;
  --color-bg: light-dark(#fafafa, #0b0b0d);
  --color-surface: light-dark(#ffffff, #16161a);
  --color-text: light-dark(#1a1a1a, #f4f4f5);
  --color-text-muted: light-dark(#71717a, #a1a1aa);
  --color-border: light-dark(#e5e5e5, #2a2a2e);
  --color-accent: light-dark(#4ade80, #22c55e);
}

每个 token 一行,改一处全生效,不会再出现亮色更新了暗色忘改的情况。

删掉组件底部的 @media 块

很多组件库每个文件底部都有一坨这样的东西:

.card { background: var(--color-surface); color: var(--color-text); }
/* ... 20 more components ... */

@media (prefers-color-scheme: dark) {
  .card { background: #16161a; color: #f4f4f5; }
  /* ... 20 more overrides ... */
}

两个问题:一是组件样式被切成两个地方,看逻辑要翻两个文件;二是当 JS 切换也参与时,媒体查询的优先级和 data-theme 的优先级谁高谁低就成了玄学。

用 light-dark() 直接写在属性里,不需要覆盖块:

.card {
  background: light-dark(#ffffff, #16161a);
  color: light-dark(#1a1a1a, #f4f4f5);
  border-color: light-dark(#e5e5e5, #2a2a2e);
}

每个组件完全自包含,不需要在底部追加覆盖代码。

解决 JS 切换和媒体查询的冲突

这是最容易被忽视的坑。很多项目的暗色模式是这么写的:

@media (prefers-color-scheme: dark) { :root { --bg: #0f0f0f; } }

同时 JS 里有:

toggleBtn.addEventListener("click", () => {
  document.documentElement.dataset.theme =
    document.documentElement.dataset.theme === "dark" ? "light" : "dark";
});

然后就出现经典问题:用户手动选了暗色,系统 later 切换了(比如外接了投影仪),媒体查询重新匹配,data-theme 的覆盖规则就乱了。哪个优先很难说清楚。

light-dark() 的方案让这个冲突消失:

:root { color-scheme: light dark; }
body { background: light-dark(#fafafa, #0f0f0f); }

此时 JS 只需要控制 color-scheme 的值就行:

toggleBtn.addEventListener("click", () => {
  const isDark = document.documentElement.style.colorScheme === "dark";
  document.documentElement.style.colorScheme = isDark ? "light" : "dark";
});

媒体查询和 JS 切换各走各的,不会互相踩踏。

浏览器支持

Chrome 123+、Firefox 120+、Safari 17.5+,全球约 90% 覆盖率,已经是 Baseline Widely Available。没有 polyfill,直接用就行。

唯一需要注意的:Safari 暂不支持 light-dark() 接收图片值(light-dark(url(a.svg), url(b.svg))),如果你的 logo 或 hero 图需要暗色版本,用 picture 元素做降级。

怎么落地

三步走:

  1. 先确认 :root 有 color-scheme: light dark
  2. 找一个颜色变量重复最多的文件,把两套变量合并成 light-dark() 形式
  3. 加 @supports (color: light-dark(#fff, #000)) 渐进增强,给还不支持的浏览器留默认色

一个文件改完,你就知道这个函数值不值了。

评论区

0 条评论

登录后可评论。

小鹿·界面实验室 160 阅读