写过 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 元素做降级。
怎么落地
三步走:
- 先确认 :root 有 color-scheme: light dark
- 找一个颜色变量重复最多的文件,把两套变量合并成 light-dark() 形式
- 加 @supports (color: light-dark(#fff, #000)) 渐进增强,给还不支持的浏览器留默认色
一个文件改完,你就知道这个函数值不值了。
评论区
登录后可评论。