深色模式切换功能如今已成为网站的标配特性。有趣的是,许多开发者在实现时都卡在了同一个环节——主题颜色切换时缺乏平滑过渡效果,颜色是“啪”一下直接跳变,没有任何动画衔接。问题究竟出在哪里?其实原理并不复杂,但细节中的坑确实不少。

核心要点就一句话:别想着给 --bg-color 这类自定义属性直接加 transition,那是无效操作。你必须把过渡效果写在浏览器能识别的原生属性上,比如 background-color,并且要确保起始值和结束值都是实实在在、可计算的具体颜色值。
为什么 transition: --bg-color 0.3s 完全不起作用
这是很多人踩的第一个坑。CSS 规范明确规定:自定义属性不支持动画。你写 transition: --bg-color 0.3s,浏览器会直接忽略,既不报错也不执行。真正能实现过渡效果的,永远是那些原生属性:background-color、color、border-color、opacity,仅此而已。
- 可以这样理解:
--bg-color只是一个占位符,实际渲染时会被替换成具体颜色值。过渡动画发生在这些具体值之间。 - 如果颜色值的一端是
transparent或inherit,插值计算就会中断,甚至直接产生跳变。 - 有人会想到用
@property声明可动画变量?Chrome 从 101 版本开始支持,但 Safari 至今仍不支持,生产环境不建议使用。
transition 必须写在默认状态规则中
另一个常见错误是只在 .dark 类里写 transition。结果切换时该闪还是闪。为什么?因为默认状态下浏览器根本没有定义过渡行为。当你从“无过渡”的默认状态切换到“有过渡”的深色状态时,第一帧画面就是直接跳过去的。
- 正确做法是把
transition写在默认状态的规则里,比如body上:body { background-color: #ffffff; color: #333333; transition: background-color 0.35s ease-in-out, color 0.35s ease-in-out; } .dark类里只需要提供目标值:background-color: #121212; color: #e0e0e0;- 所有参与过渡的属性,在默认状态和
.dark状态里,都必须写死具体的颜色值,不能留空,也不能用initial。这是实现平滑主题切换的基本规则。
手动切换时 class 比 data-theme 更可靠
使用 document.documentElement.classList.toggle('dark') 是最轻量、最可控的深色模式切换方式。data-theme="dark" 本身不会触发样式重算,需要配合选择器(如 [data-theme="dark"])才能生效,且这个选择器的优先级必须高于 :root 中的默认声明。
- 正确的选择器写法是
html[data-theme="dark"]或[data-theme="dark"],像html[data-theme="dark"] :root这种写法不会生效。 - 忘记同步 class 和
localStorage?刷新页面后主题设置一定会丢失。 - 初始化阶段最容易出现闪屏。关键是在 DOM 加载前就把 class 设置好,否则首屏白底闪一下再变黑,用户体验很差。
移动端闪屏主因是初始渲染时机不对
这个问题在 iOS Safari 和 Android WebView 上尤其明显。系统偏好读取是异步的,但 HTML 和 CSS 解析是同步的。:root 里默认的 --bg: #fff 已经完成了首次渲染,等媒体查询匹配上才去重绘,时间差就这样产生了。
- 必须在
里内联一段轻量 JS,用matchMedia('(prefers-color-scheme: dark)')同步检测系统深色模式偏好。 - 检测到后立即给
添加data-theme="dark"或对应 class。千万别等到DOMContentLoaded事件后再操作,到那时已经来不及了。 - 所有主题变量必须在顶层
:root中先声明默认值。使用变量时必须带上 fallback 值:background-color: var(--bg, #ffffff)。
说到底,真正实现平滑主题颜色切换的难点,不在于写那一行 transition,而是要确保变量已经定义、class 已经存在、transition 已经提前声明、初始颜色值可以插值计算。这几个环节,漏掉任何一个,结果就只剩下颜色跳变了。
