深色模式适配实战:prefers-color-scheme搭配CSS变量,从原理到落地 做了这么多年前端说实话“深色模式”已经从加分项变成了不少用户的基本诉求。很多朋友晚上喜欢把系统调到深色打开网站却迎面一片高亮白底眼睛是真遭罪。以前大家习惯用 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-themelight] { --color-bg: #f7f7f8; --color-surface: #ffffff; --color-text: #1c1c1e; --color-border: #e2e2e6; } /* 第四层手动深色覆盖 */ [data-themedark] { --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被加了一层透明度和亮度后视觉会发灰反而看不清。稳妥的做法是给纯装饰图片加classtheme-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在根样式与>