
深色模式这几年已经成为操作系统的“标配功能”了手机、电脑、浏览器全都跟着系统偏好走。用户把系统切成深色一个刺眼的白色网页就摆在眼前体验上确实很割裂。CSS里真正能解决这个问题的就是prefers-color-scheme这个媒体查询特性再配合CSS变量Custom Properties来做颜色体系的统一管理。这篇文章我会直接讲透这套组合拳怎么打从最基础的变量定义、媒体查询覆盖到图片、表单控件、滚动条这些容易翻车的细节再到light-dark()、color-scheme、JavaScript手动切换这些进阶玩法全程用可复制的代码和踩坑实录争取让你看完就能直接在自己的项目里落地。1. 整体设计思路拆解为什么是prefers-color-scheme 变量定义1.1 三个方案选型的底层逻辑实现深色模式业界大致有三条路第一条是老老实实写media (prefers-color-scheme: dark)让浏览器读取系统设置第二条是用.dark这种class在根元素上做切换配合JavaScript控制第三条是后端根据请求头里的Sec-CH-Prefers-Color-Scheme直接返回深浅两套页面这个属于进阶中的进阶了一般团队很少用。我优先推荐第一条方案不是因为它最时髦而是它最符合“Web默认应该尊重用户系统设置”的交互直觉。用户是在系统层面做的偏好选择网页不需要再去问一遍“你喜欢深色还是浅色”。两条腿走路的方式也很常见默认用prefers-color-scheme自动适配同时允许用户在页面上手动切换再存到localStorage里这种情况一般通过设置>:root { --bg-page: #f5f6f8; --bg-card: #ffffff; --text-primary: #17181a; --text-secondary: #5f6368; --border-color: #e0e2e6; --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); }2.2 媒体查询里的变量覆盖作用域的巧妙之处prefers-color-scheme的使用方式跟普通的media (max-width: 768px)一样区别在于它匹配的是系统颜色偏好而不是视口宽度。你可以在媒体查询里重新定义:root上的变量值因为:root就相当于html元素变量在这里定义全站都能继承到。media (prefers-color-scheme: dark) { :root { --bg-page: #121316; --bg-card: #1d1e22; --text-primary: #e8eaed; --text-secondary: #9aa0a6; --border-color: #35363a; --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); } }媒体查询里写:root和写html效果一样但:root的优先级更高而且语义更清晰。变量覆盖时要注意media并不会改变选择器的优先级规则它只是在命中条件时覆盖同选择器的属性值所以你在:root外面不要再套一层html选择器做重复定义那只是给自己制造排查麻烦。另外CSS变量是可继承的这意味着你完全可以在某个局部容器里重新定义变量值容器内所有子元素全部响应。这个特性用来做局部微调很爽比如某个管理后台的侧边栏在深色模式下想要更暗一档直接给侧边栏加一个类在类里重新覆盖--bg-card就行。2.3 一个可以直接抄的样板结构为了让你看得更明白我整理了一个带按钮、卡片、表单的完整基础示例这套结构放在任何项目里都能直接跑通!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta namecolor-scheme contentlight dark / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title深色模式适配示例/title style :root { --bg-page: #f5f6f8; --bg-card: #ffffff; --text-primary: #17181a; --text-secondary: #5f6368; --brand-primary: #2d6cdf; --brand-text: #ffffff; --border-color: #e0e2e6; --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); } media (prefers-color-scheme: dark) { :root { --bg-page: #121316; --bg-card: #1d1e22; --text-primary: #e8eaed; --text-secondary: #9aa0a6; --brand-primary: #5b8def; --brand-text: #ffffff; --border-color: #35363a; --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); } } * { box-sizing: border-box; transition: background-color 0.25s, border-color 0.25s, color 0.25s; } body { margin: 0; background-color: var(--bg-page); color: var(--text-primary); font-family: system-ui, -apple-system, PingFang SC, Microsoft YaHei, sans-serif; line-height: 1.6; } .card { max-width: 420px; margin: 48px auto; padding: 24px; background-color: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; box-shadow: var(--shadow-md); } .btn { display: inline-block; padding: 8px 18px; background-color: var(--brand-primary); color: var(--brand-text); border: none; border-radius: 6px; cursor: pointer; } /style /head body div classcard h2这是一张卡片/h2 p背景、文字、边框、阴影全部由CSS变量驱动。/p button classbtn主按钮/button /div /body /html这段代码里我特意加了transition这是一个很容易被忽略但实际效果非常好的细节。系统颜色偏好切换的一瞬间如果页面立刻从白变黑视觉冲击会很强加上0.25s的过渡动画后颜色渐变过程会平滑很多观感上高级不少。但要注意transition加在*上会带来一个副作用页面初始加载时所有元素会“渐入”一次。很多团队因此不推荐全局过渡折中方案是只在主题相关属性上做过渡不在*上写transition: all因为all会把transform、opacity这些属性也纳入过渡反而会影响性能表现。3. 实操过程与核心环节实现深色模式适配的完整落地3.1 图片与背景的处理别让亮图刺穿深色氛围深色模式下最出戏的就是图片。一张白色背景的产品图放在深色卡片上那感觉就像黑夜中开了一盏探照灯。图片处理有几个常用手段我按推荐程度排一下最推荐的手段是在深色模式下降低亮度、提高对比度。通过CSS的filter属性就能实现成本极低效果立竿见影media (prefers-color-scheme: dark) { img { filter: brightness(0.8) contrast(1.2); } }这套参数的意思是亮度降为原来的80%对比度提升到120%。实际观感上图片会“沉”进深色背景里但又不会糊成一团。如果图片本身有很强的品牌色调节的时候要控制幅度亮度建议在0.75到0.9之间太高了压不住太低了直接看不清楚。还有一种思路是直接用编程方式给图片增加暗色遮罩——在图片外面包一层容器容器上用::after放一个半透明黑色渐变层。这个方案适合照片墙、大图背景这类场景文字放在图片上时还能顺便提升可读性。如果图片格式支持最优雅的其实是使用picture标签配合media属性在深色模式下加载同一图片的深色版本picture source srcsetbg-dark.png media(prefers-color-scheme: dark) / img srcbg-light.png alt背景示例 / /picture这个方案的缺点是需要设计师专门输出一套深色图资源成本高但对视觉效果要求严格的项目值得这么做。需要特别说一句不要试图用CSS反色技巧处理图片比如filter: invert(1)这种操作。反转像素颜色会把人脸肤色变成青色商品图直接没法看只有一些抽象图形、Icon类素材才能用。3.2 表单控件、滚动条和原生UI组件的深色适配很多项目的深色适配坑不在div和span而在浏览器原生控件。input、select、textarea的默认外观、下拉箭头的颜色、滚动条的颜色这些都不归你的普通CSS变量管它们由浏览器自己绘制。先说表单。要让浏览器用深色风格渲染原生控件有两个关键设置缺一不可:root { color-scheme: light dark; }在HTML里也需要加上对应的meta标签让浏览器在解析阶段就知道页面支持深浅两种配色meta namecolor-scheme contentlight dark /设置了color-scheme之后Chrome和Safari会把默认表单背景、文字颜色、滚动条、搜索框的取消按钮全部切换成深色体系。这个属性解决的是系统控件层的适配和你用CSS变量自己控制的页面层适配刚好互补。如果不设置它你会遇到一个非常尴尬的情况页面整体是深色的但输入框依然是白底——这块白在深色界面里特别扎眼。再把视野放到移动端Android的Chrome和iOS的Safari在滚动时都会显示滚动条或滚动指示器系统颜色偏好深色时滚动条自动变成深色。但是如果你给某个元素单独设置了.light的color-scheme: light那这个滚动条又会变回浅色所以混合模式下颜色作用域要格外小心。3.3 阴影与边框深色模式不能只改色值深色模式下阴影这个设计语言需要“额外处理”。在浅色模式里阴影的作用是模拟高度给卡片一种浮起来的感觉但在深色模式里黑色背景上的黑色阴影几乎不可见所以很多深色UI会选择用边框或更亮的背景色来区分层级。我的做法是在深色模式下把阴影值调得更弱同时把边框调亮一点。也就是说在:root默认变量里写明完整阴影在media (prefers-color-scheme: dark)里把阴影压暗、把边框变量提亮。如果你想让卡片层级更清晰可以做“比页面背景亮一档的背景色”加“低透明度边框”的组合这也是Material Design等设计规范在深色主题下的通用做法。:root { --bg-page: #f5f6f8; --bg-card: #ffffff; --border-color: transparent; --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); } media (prefers-color-scheme: dark) { :root { --bg-page: #121316; --bg-card: #1d1e22; --border-color: rgba(255, 255, 255, 0.08); --shadow-md: none; } }这样深色模式下卡片和页面背景之间靠亮度差来区分而不是靠阴影。这种小细节才是深色模式做得到不到位的关键。3.4 第三方组件与内联样式的适配思路真实项目里页面不可能是纯原生的总有一些第三方组件库比如Element Plus、Ant Design或者你自己封装的老组件。这些组件的颜色值往往是写死在组件内部样式里的你想用CSS变量去覆盖难度主要看组件是不是设计成“可定制”的。对支持自定义主题的组件库直接找到它们的主题变量入口映射到你的CSS变量体系上。对不支持主题变量的组件最直接的办法是用CSS变量覆盖 更深层的选择器强行覆盖或者用!important做最后兜底。这里有个方法论上的建议把组件库里所有需要适配的颜色在入口文件中先定义成一套局部CSS变量然后再通过媒体查询统一替换避免在组件内部到处改。.el-button--primary { background-color: var(--brand-primary); }如果有元素直接用了内联样式stylebackground: #fffCSS变量机制天然覆盖不了因为内联样式的优先级高于任何选择器。解决方案要么是改HTML模板去掉内联样式要么用JavaScript在运行时把内联样式里的颜色值替换为变量引用——但后者非常麻烦建议从代码规范层面就禁止写死颜色。4. 进阶方案与浏览器特性模式切换和持久化4.1 用light-dark()简化变量覆盖如果你讨厌在media里把十几个变量重写一遍那light-dark()函数就是为你准备的。它是CSS Color Module Level 5里的新函数可以让你在单个属性值里直接指定深、浅两种模式下的颜色一个值搞定两个状态:root { color-scheme: light dark; } body { background-color: light-dark(#f5f6f8, #121316); color: light-dark(#17181a, #e8eaed); }这里有个容易踩的坑light-dark()必须配合color-scheme才能生效因为它的判断依据就是元素的color-scheme计算值。如果你只在:root写了color-scheme: light dark那普通元素会自动继承这没问题但如果你单独给某个组件设置了color-scheme: light那么即使系统处于深色模式light-dark()也会返回浅色值。light-dark()目前的浏览器支持度已经不错了主流现代浏览器都能识别但如果你需要兼容较老版本建议在它外面套一层备用声明body { background-color: #f5f6f8; background-color: light-dark(#f5f6f8, #121316); }这种渐进增强的写法对老浏览器很友好支持light-dark()的用新函数不支持的自动回退到默认浅色。4.2 手动切换 自动跟随完整的方案闭环前面说的都是“纯自动”模式跟随系统。但实际产品里几乎所有深色模式需求最终都会演变成“自动 手动选择 记忆用户偏好”。用户可能有自己独立的偏好系统是深色他就想用浅色或者反过来。实现思路很清晰默认不做任何额外处理让CSS自动跟随系统当用户手动点击切换按钮时往html根元素上设置>:root { --bg-page: #f5f6f8; } [data-themedark] { --bg-page: #121316; } media (prefers-color-scheme: dark) { :root { --bg-page: #121316; } } [data-themelight] { --bg-page: #f5f6f8; }这种写法会有一个优先级算法上的坑如果[data-themedark]写在media之后那么[data-themelight]又写在最下面整个优先级变得非常绕。我推荐的做法是统一把三个来源分开层级系统层面用媒体查询用户层面用>const root document.documentElement; const savedTheme localStorage.getItem(theme); function applyTheme(theme) { root.setAttribute(data-theme, theme); } if (savedTheme) { applyTheme(savedTheme); } else { const mql window.matchMedia((prefers-color-scheme: dark)); if (mql.matches) { applyTheme(dark); } } // 监听系统主题变化仅当用户未手动选择时才生效 window.matchMedia((prefers-color-scheme: dark)) .addEventListener(change, (event) { if (!localStorage.getItem(theme)) { applyTheme(event.matches ? dark : light); } });注意这里用到了window.matchMedia((prefers-color-scheme: dark))它的返回值跟CSS媒体查询是同一套匹配逻辑而且change事件绑定是监听系统偏好变化的标准方式。在Safari旧版本里addEventListener可能不被支持需要降级用addListener不过现在iOS 14以上的版本基本都没问题了。4.3 让整个页面平滑过渡切换动画的细节前面我在样板代码里写了全局transition但真正的进阶做法是只针对颜色相关属性做过渡同时避免页面加载时出现首帧闪烁。首帧闪烁的根本原因有两种第一种是JS在/body之前执行用户能看到先渲染的浅色页面然后突然跳成深色第二种是CSS变量覆盖逻辑写得靠后浏览器先画完第一版才执行媒体查询。要根治这个问题可以在head里直接内联一段小脚本在页面渲染前读取localStorage和系统偏好然后立即给html设置属性。这种方式叫“FOUC防护”虽然不完美但能极大降低闪烁概率。另外一个细节是加载默认CSS时如果已经检测到深色模式可以干脆给html加一个类让元素初始就不需要动画过渡。5. 常见问题与排查技巧实录5.1 prefers-color-scheme不生效很多人写完媒体查询发现怎么切换系统主题页面都没反应。第一反应是浏览器兼容性问题但实际上大多数情况下是这三个原因一是浏览器不支持。prefers-color-scheme在Chrome 76、Firefox 67、Safari 12.1之后才支持如果用户还在用老版本Chrome媒体查询会被直接跳过。二是系统层面没有开启深色模式或者开启了但是应用本身不支持深色外观这种情况在macOS上常见浏览器读不到深色偏好。三是CSS里被其他同名选择器覆盖了。最常见的是有人写了html { ... }然后又写了:root { ... }或者某些全局样式的优先级盖过了媒体查询。排查技巧其实就一条打开Chrome DevTools的Rendering面板勾选Emulate CSS media feature prefers-color-scheme: dark看页面是否立即变化。如果模拟后变了说明代码本身没问题问题出在系统设置如果模拟后依然没变那就要去看是不是有更高优先级的选择器挡在前面。5.2 CSS变量没生效变量名字拼写错误是最高频事故。--text-primary和--textPrimary是两个完全不同的变量在:root中定义了前者使用处却写了后者浏览器不会报错只会忽略页面表现就是“没有样式”。用var(--brand-primary, #2d6cdf)这种方式带上兜底值能有效降低这种状况对线上的影响。还有一种情况是变量被局部的inherit给覆盖了。比如一个组件的父级重新定义了--text-primary这个组件内部所有用var(--text-primary)的地方都会跟着变这当然是我们想要的但如果第三方组件的或外部的div不小心定义了同名变量那就会造成“局部颜色异常”。排查思路是把鼠标放到对应元素上在DevTools的Computed面板查看变量解析后的实际值顺着继承链条逐层排查。5.3 图片在深色模式下太亮首选方案是filter: brightness(0.8) contrast(1.2)这个前面已经讲过。如果图片本身内容复杂降低亮度后细节丢失可以考虑用混合模式比如给图片容器加一个mix-blend-mode: luminosity的遮罩让图片整体饱和度下降更贴近深色UI的氛围。不过混合模式在某些浏览器上会触发重绘性能偶发波动量级较大的页面慎用。更根本的解决思路其实不在CSS而在产品侧尽量让设计师输出支持透明背景或带深色底色的图片素材或者尽量使用非图片的元素去呈现视觉信息比如纯CSS图标、SVG图标。SVG图标天然可以用currentColor继承文字颜色如果你的图标大量是亮色系直接用color变量控制即可。5.4 快速排查工具与测试清单给你整理一套我平时做深色模式验收时的“快速测试清单”能覆盖绝大多数场景测试项操作方式期望结果系统偏好模拟DevTools Rendering面板切换prefers-color-scheme页面颜色、组件、字体颜色全部切换本地链接跳转深色模式下刷新页面无首屏白闪表单控件点击input、select背景、光标、下拉箭头颜色正常滚动条长页面滚动滚动条与页面背景协调图片与媒体浏览图片列表图片亮度不刺眼可读性OK组件库样式打开所有组件案例无大面积亮色残留手动切换点击页面上的深浅切换按钮并刷新用户选择被持久化记忆这套清单看着简单但绝大多数“深色模式做了一半就放弃”的项目都是倒在表单控件和图片这两关上的。尤其是color-scheme没有设置的情况下表单控件那块硬伤几乎无法只靠CSS变量解决。写在最后的几句实操体会做深色模式适配这三年我个人最大的体会是技术方案从来不是瓶颈设计变量的梳理和业务的配合才是。prefers-color-scheme和CSS变量这套组合本质是给全站的颜色体系做了一次“抽象化重构”如果业务方没有提前梳理好颜色清单、临时起意要支持深色模式那后面大概率会陷入到无穷无尽的“这里还要再改一下”里。所以如果你现在正好要启动深色模式改造我建议你第一步不是写代码而是拉上设计同学把全站的颜色“普查”一遍列出一张《颜色变量映射表》每一行对应浅色和深色两个值再照着这张表去填写:root和media里的变量。第二步才是在代码里搭建变量体系和媒体查询覆盖。第三步是做兼容性处理该加color-scheme的加该做light-dark()渐进增强的做。顺序对了整个过程会很轻松顺序反了那就是一个填不完的坑。最后再分享一个小技巧调试深色模式不一定非要在真机上反复开关系统设置用DevTools的Rendering面板可以快速模拟深浅切换。但如果你的样式涉及Safari独有的渲染表现建议还是备一台iOS设备和一台Android真机毕竟浏览器之间的默认控件外观差异只有真机才看得见。