做了这么多年前端,说实话“深色模式”已经从加分项变成了不少用户的基本诉求。很多朋友晚上喜欢把系统调到深色,打开网站却迎面一片高亮白底,眼睛是真遭罪。以前大家习惯用 JS 判断系统主题然后往根节点塞 class,后来 CSS 原生给出的方案成熟多了:用 prefers-color-scheme 媒体查询配合 CSS 变量定义,就能让页面自动跟随系统深浅色切换,代码量少、可维护性高,还能无缝扩展手动切换。这篇文章我不打算只贴一段现成代码,而是把整套方案从原理、设计到真实项目会踩的坑,完整拆开讲一遍,适合刚接触深色适配的朋友,也适合手头有老项目想低成本改造的开发者。
1. 深色模式适配的整体思路与技术选型
1.1 别急着写代码,先想清你要的是“滤镜式”还是“组件级适配”
网上经常能看到一种非常取巧的深色模式实现,给整个页面套一个 CSS 滤镜:
html { filter: invert(1) hue-rotate(180deg); }这种“一键反转”的方案看起来很爽,实际用起来问题一堆。图片被反成了胶片底片,按钮阴影翻得乱七八糟,视频画面根本没法看。你还要额外给图片、视频再加一层反滤镜去补救,越救越乱,最终代码像打补丁一样惨不忍睹。
真正合格的深色适配,是针对每个颜色角色重新定义值,让页面在暗色环境下依然有清晰的信息层级。背景不是简单拿来一个纯黑盖上,文字也不是把白色下拉成灰色就完事。这里要的是“组件级”的适配:背景、前景、边框、主色、次要文字、阴影、hover 态、focus 态,每一处都要有对应关系。用 CSS 变量和媒体查询作为骨架,正好能支撑这种精细控制。
1.2 为什么我推荐 prefers-color-scheme + 变量这套组合
现在做前端,深色适配常见的候选方案有三类:
第一类是纯 JS 方案,用window.matchMedia('(prefers-color-scheme: dark)')监听系统主题,然后往根节点添加 class。这个方案能工作,但页面初始化时要等 JS 执行完才知道该用哪个主题,容易产生闪白,还要额外处理监听事件的清理。
第二类是纯 CSS 方案但不抽变量,在@media (prefers-color-scheme: dark)里把每个组件的颜色重新写一遍。这种写法遇到大型项目就是灾难,一个按钮改了颜色,你可能要跑到好几个样式文件里同步改。
第三类就是 prefers-color-scheme + CSS 变量。核心逻辑是:把所有会变色的值抽到一个变量表,亮色一套值,暗色一套值,媒体查询只在命中时覆盖变量本身。页面上其他地方统一使用var(--xxx)。这样换主题时,浏览器只需要替换变量,所有依赖于该变量的 CSS 声明自动更新,维护成本最低,渲染性能也是原生的。
从实践经验看,这套方案还额外获得了两个好处:一是后续加“手动切换按钮”时,不需要推翻重写,只需要在原有变量体系上增加>@media (prefers-color-scheme: dark) { /* 系统处于深色模式时的样式 */ :root { --color-bg: #1c1c1e; } } @media (prefers-color-scheme: light) { /* 系统处于浅色模式时的样式 */ :root { --color-bg: #f7f7f8; } } @media (prefers-color-scheme: no-preference) { /* 系统没有表达偏好,一般按浅色处理 */ }
实际开发中,no-preference用得很少,大多数情况下把默认样式写在:root里当作浅色兜底,只用dark分支覆盖即可。
兼容性方面,现代浏览器基本全覆盖了,Chrome 76+、Edge 79+、Firefox 67+、Safari 12.1+ 都支持。对国内开发者来说,唯一要注意的是某些低版本 WebView 和内嵌浏览器环境可能不认识这条媒体查询。好在 CSS 本身有“优雅降级”的特性,不认识的媒体查询会被直接忽略,默认浅色样式依然生效,不会出现页面崩坏的问题,只是深色不适配而已。
还有一个容易忽略的点:这条媒体查询不只作用于颜色。如果用户系统开启了“强制深色”“高对比度”等辅助功能,部分浏览器会把prefers-color-scheme的判定结果一并影响。测试时不要把系统主题停留在窗口标题栏的浅色模式,应该在操作系统设置里整体切换外观,或者用开发者工具模拟。
2.2 CSS 变量(自定义属性)的核心逻辑
CSS 变量,标准名称叫“自定义属性”,写法是--变量名: 值,读取时用var(--变量名)。它的关键特征是“继承 + 覆盖”。继承意味着你在:root定义了一个变量,所有后代元素默认都能读到;覆盖意味着你在某个子元素上重新定义同名变量,这个子元素及其后代就会使用新值。
:root { --accent: #0a84ff; } .card { --accent: #ff9f0a; } p { color: var(--accent); }这段代码里,普通段落文字颜色是蓝色,但.card内部的段落会变成橙色。这正是做深色模式需要的机制:不需要为每个具体元素写两遍颜色,只要在一处改变变量值,所有引用点全部跟着变。
变量还能做回退设置,var(--color-bg, #ffffff)表示当第一个值不存在时使用备用色。这个特性在改造老项目时特别有用,可以先给所有新代码用变量,旧代码没来得及抽色的部分保留兜底,不会直接失效。
2.3 二者配合的关键点
prefers-color-scheme 负责“识别环境”,CSS 变量负责“统一换肤”。两者配合的典型模板长这样:
:root { --color-bg: #f7f7f8; --color-surface: #ffffff; --color-text: #1c1c1e; --color-border: #e2e2e6; } @media (prefers-color-scheme: dark) { :root { --color-bg: #1c1c1e; --color-surface: #2c2c2e; --color-text: #f2f2f7; --color-border: #3a3a3c; } } body { background-color: var(--color-bg); color: var(--color-text); }注意一个细节:媒体查询内部依然选择:root,而不是body或html。原因是变量需要从根节点向下继承,如果在body上覆盖,html或更早的祖先元素里的引用就取不到新值。我一直认为,这套组合最大的价值在于把“环境判断”和“可视样式”彻底解耦了。项目里绝大多数样式文件完全不需要知道深浅色的存在,它们只关心var(--color-text)是什么,至于这个变量是在暗色分支还是在亮色分支里定义,全交给根节点那几行变量表去管理。
3. 五步落地:把亮色项目改造为自动适配深色模式
3.1 第一步:从设计稿里整理出一套语义化颜色变量
有朋友上来就写代码,改着改着发现颜色越改越乱。我习惯在动手前先拿着设计稿列一张变量清单,把页面里所有出现过的颜色归纳成“角色”。常见角色有:
- 页面背景
- 卡片或浮层背景
- 正文文字
- 次要文字
- 边框分隔线
- 主色与主色 hover
- 危险色、成功色
- 阴影
命名时不要写--white、--black这种物理名,要写--color-surface、--color-text-secondary这种语义名。同样是白色,在背景里和卡片里含义不同,换成语义名后后期调黑暗配色才能独立调整。我一般会在:root里先写亮色值,并附上简单注释,方便后面维护。
3.2 第二步:写暗色分支,只改变量不碰组件
变量表定义完,紧接着写@media (prefers-color-scheme: dark)分支,在分支里重新给变量赋值。这时要关注的不是“哪个组件”,而是“这个变量代表的颜色角色在暗背景下应该是什么样”。背景要从浅白调到深灰,但尽量不要用纯黑#000,纯黑在 OLED 屏上虽然省电,但和旁边深色文字、深色卡片之间缺乏层次,视觉容易发闷。卡片浮层要比背景稍微亮一档,形成“凹背景、凸卡片”的层次感。文字也一样,亮色模式下正文是接近黑的深灰,暗色模式下正文变成接近白的浅灰。
3.3 第三步:把页面样式里的写死颜色替换成 var()
变量表准备好后,剩下来的工作就是全局搜索#开头的颜色值,逐个替换成var(--xxx)。替换时有个技巧,如果颜色带有透明度,可以继续沿用 rgba 写法,把变量作为 rgb 三通道的值使用:
:root { --color-primary-rgb: 10, 132, 255; } .button { background-color: rgba(var(--color-primary-rgb), 0.8); }这样在深浅模式下既能复用主色变量,又能保留透明度。替换工作看着机械,实际最容易出错的是“灰”字系颜色,不同元素用过的灰色可能来自多个相近色号,统一成同一个--color-border后,可能在某个组件上显得边框太深或太浅。替换完建议把同一类元素集中检查一遍,而不是只盯着单个元素。
3.4 第四步:不要忘了交互态和辅助态
颜色替换阶段最容易漏掉的是 hover、active、focus 和 disabled 这类交互状态。以按钮为例,亮色下主色按钮 hover 变深一点,暗色下如果还用同一个深色 hover,对比度会不够明显。正确做法是把主色 hover 也抽成变量,在暗色分支单独定义:
:root { --color-primary: #0a84ff; --color-primary-hover: #0070e0; } @media (prefers-color-scheme: dark) { :root { --color-primary: #0a84ff; --color-primary-hover: #409cff; } } .button { background-color: var(--color-primary); } .button:hover { background-color: var(--color-primary-hover); }另外,链接下划线、列表 hover 背景、输入框 focus 光环这些细节,也都要纳入变量体系。很多项目深色模式看着“脏”,就是因为 hover 态颜色没跟上,鼠标移入后突然跳出一块刺眼的亮底色。我还会顺手检查删除线、下划线这类辅助样式,工具类场景里它们也参与视觉平衡,只是在暗色底上需要更柔和的颜色。
3.5 第五步:用 color-scheme 把原生控件也拉进来
CSS 变量只能控制我们用 CSS 绘制的部分,滚动条、下拉框、日期选择器这类原生控件,浏览器默认样式并不会因为你的页面变量改变而改变。要解决这个问题,需要用color-scheme属性告诉浏览器当前页面支持的颜色方案:
:root { color-scheme: light; } @media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }加上这条之后,暗色模式下浏览器原生滚动条会变暗,表单控件配色也会跟随。React、Vue 这类框架项目里,如果已经做了手动切换主题,记得把color-scheme一并按>/* 第一层:默认浅色 */ :root { --color-bg: #f7f7f8; --color-surface: #ffffff; --color-text: #1c1c1e; --color-border: #e2e2e6; } /* 第二层:系统深色自动覆盖 */ @media (prefers-color-scheme: dark) { :root { --color-bg: #1c1c1e; --color-surface: #2c2c2e; --color-text: #f2f2f7; --color-border: #3a3a3c; } } /* 第三层:手动浅色覆盖 */ [data-theme="light"] { --color-bg: #f7f7f8; --color-surface: #ffffff; --color-text: #1c1c1e; --color-border: #e2e2e6; } /* 第四层:手动深色覆盖 */ [data-theme="dark"] { --color-bg: #1c1c1e; --color-surface: #2c2c2e; --color-text: #f2f2f7; --color-border: #3a3a3c; }
这样当根节点没有>function applyTheme(theme) { const root = document.documentElement; if (theme === 'system') { root.removeAttribute('data-theme'); } else { root.setAttribute('data-theme', theme); } localStorage.setItem('theme', theme); } function initTheme() { const saved = localStorage.getItem('theme') || 'system'; applyTheme(saved); } function toggleTheme() { const saved = localStorage.getItem('theme') || 'system'; let next; if (saved === 'system') { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; next = isDark ? 'light' : 'dark'; } else { next = saved === 'dark' ? 'light' : 'dark'; } applyTheme(next); } initTheme();
这段代码里注意一个细节:从“跟随系统”切到手动模式时,先通过matchMedia判断当前系统实际主题,然后切到相反的另一个主题。如果当前系统是深色,用户点击切换按钮,预期是进入浅色;如果当前系统是浅色,则进入深色。这样第一次点击不会出现“点了一下看起来没变化”的困惑。
4.4 防止页面加载时闪白闪黑
自动跟随方案最大的痛点出现在首屏:HTML 已经解析,CSS 还没完整应用,用户先看到一版默认亮色,然后系统暗色分支突然生效,页面啪地闪一下。这个问题 SP 和移动端 H5 都容易遇到。
最可靠的方式是在<head>里放一段极小的内联脚本,在 CSS 解析渲染前把主题设置好:
<script> (function () { var theme = localStorage.getItem('theme') || 'system'; if (theme === 'dark') { document.documentElement.setAttribute('data-theme', 'dark'); } else if (theme === 'light') { document.documentElement.setAttribute('data-theme', 'light'); } })(); </script>这段脚本执行时,页面还没渲染,根节点上已经挂好了>@media (prefers-color-scheme: dark) { img, video { opacity: 0.85; filter: brightness(0.9); } }
这样图片在暗色背景上会稍微“沉”下去,不会像贴上去的光源一样抢注意力。实际使用时,要注意别误伤图标类图片,按钮里的 icon 如果是一张 PNG,被加了一层透明度和亮度后,视觉会发灰,反而看不清。稳妥的做法是给纯装饰图片加class="theme-dim",只在需要弱化的图片上应用这套滤镜,或者用 CSS 选择器排除图标常用类。
5.2 阴影在暗色下要“收着点”
亮色模式下,阴影是制造层次感的重要工具,卡片靠一层淡淡的投影就能从背景里浮起来。暗色模式下,深色背景上的阴影很难被感知,而且大面积灰黑色投影会让画面变脏。我一般在暗色分支把阴影的透明度调低,或者改用“边框 + 内阴影”的组合来表达层级:
:root { --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.08); } @media (prefers-color-scheme: dark) { :root { --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.45); } }在暗色模式下设置阴影时,我会刻意把阴影颜色往“近黑”的方向调,同时降低像素模糊值,让层次更克制。阴影变量同样要从具体色值里抽出来,否则深色模式下每个组件的阴影各自为政,效果非常乱。
5.3 别忽略滚动条和表单控件
前面提到的color-scheme能解决大部分原生控件颜色,但它只影响浏览器默认样式,如果你已经用 CSS 重度自定义过滚动条,那还需要单独处理。比如 Webkit 内核的自定义滚动条:
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-thumb { background-color: var(--color-scrollbar); border-radius: 4px; } ::-webkit-scrollbar-track { background-color: transparent; }把--color-scrollbar分别放进亮暗分支,浅色下用浅灰,暗色下用中灰。表单的 placeholder 也不要用死灰色,最好抽成变量,再配合焦点态的 focus 边框变量,整套表单在暗色下才不会出现“熊猫眼”。
5.4 色彩对比度与品牌色的平衡
做暗色配色时,我最怕的是只把背景变深,文字却还用原来的灰色。宏观上的深色方案,不代表所有元素都可以低对比度。日常阅读文字与背景的对比度,应尽量保持在 4.5:1 以上,这是 WCAG AA 级别的常见标准。实际操作中可以用浏览器 DevTools 的颜色检查器看对比度数值,达不到就调文字亮度或背景亮度。
品牌色在暗色模式下的处理也有讲究。有些品牌色在白色背景上很提气,放到深色背景上却暗沉一片。这时不是把品牌色整套换掉,而是把“主色按钮文字”与“主色背景”的关系重新配比,或者将品牌色在主背景上的展开面积减小一些。我见过不少项目直接在暗色下把品牌色换成亮度更高的同色系,这样既保留品牌识别度,又保证可读性。
6. 常见问题与排查技巧
6.1 prefers-color-scheme 不生效,先别怀疑系统坏了
遇到深色模式下页面一直不变色,优先检查三件事:浏览器版本是否支持这条媒体查询;开发者工具是否开启了“模拟深色模式”功能;CSS 文件里是不是把prefers-color-scheme写错了单词。关键字一旦打错,整条规则会被浏览器忽略,而且不会有任何报错。另外,部分低版本 WebView 即使支持 CSS 变量也不支持这条媒体查询,这在混合 App 里非常常见。定位方法很简单,在 DevTools 的 Rendering 面板里手动选一遍深色,如果样式变了,说明媒体查询本身没问题;如果没变,再去检查变量覆盖顺序。
6.2 变量被意外覆盖,颜色怎么都调不回来
变量覆盖顺序是最容易踩的坑。同一个变量在:root、body、.card等不同层级定义,子元素取值时会优先采用最近作用域的值。出现“局部颜色对不上暗色方案”的时候,先看那个元素自己有没有定义同名变量,再看它的祖先里有没有被>@media (prefers-color-scheme: dark) { * { transition: none !important; } }
第二,更精细的做法,是把颜色过渡只应用在需要平滑变化的属性上,比如background-color、color,不要全写transition: all。手动切换主题时,你会希望颜色有过渡感;自动跟随系统切换时,又希望过渡尽量短或干脆没有。动态主题下把transition拆开写,比一刀切更稳。
6.4 深色测试的几个高效技巧
我在日常开发中会增加一个极小的自测清单:切系统深色,看首页整体视觉效果;切手动浅色,确认能覆盖系统深色;切手动深色,确认在系统浅色环境下也能生效;反复切换刷新,确认没有闪白闪黑;最后检查一遍表单控件和滚动条颜色是否跟随。DevTools 里的 Rendering 面板可以模拟 prefers-color-scheme,改完不用反复去系统设置里折腾。移动端调试时,直接在 WebView 对应页面中跟随系统设置切换,能看到最真实的呈现效果。
这里再提一个辅助小工具:项目中如果嵌入了大量第三方图表或富文本编辑器,深色模式下它们很可能是独立渲染的,变量体系管不到。遇到这种情况,应该在组件初始化前读取当前主题类型,把主题作为参数传给图表配置,而不是依赖 CSS 覆盖。
6.5 常见问题速查表
| 现象 | 可能原因 | 解决路径 |
|---|---|---|
| 页面始终不变暗 | 媒体查询拼写错误或浏览器不支持 | 检查拼写,用 DevTools 模拟验证 |
| 局部颜色在暗色下仍是亮色 | 子元素定义了同名变量 | 在根节点统一管理变量,删掉局部覆盖 |
| 手动设置无效 | 手动模式规则顺序在媒体查询之前 | 把[data-theme]规则移动到媒体查询之后 |
| 原生产控件不跟随暗色 | 未设置color-scheme | 在根样式与>
|