先聊一个有意思的现象:不需要编写任何 JavaScript,仅靠一个 :checked 伪类,就能驱动整个主题切换系统。听起来很神奇,但原理其实并不复杂——核心在于,:checked 是浏览器原生状态的实时镜像,而不是 JS 模拟出来的开关。
用户点击 ,或者用键盘空格键选中它,状态更新的那一刻,CSS 重绘就同步触发了。只要在样式规则里写好了 :checked + :root 或 :checked ~ [data-theme] 这样的选择器链路,CSS 变量覆盖就在同一帧内完成。不需要 MutationObserver,也不需要手动调用 setProperty。

:checked 为什么能触发换肤,而不是靠 JS 监听?
常见的一个误解,是试图给 或 加上 :checked,但实际上它只对 radio 和 checkbox 生效。还有一个更隐蔽的坑:radio 如果没有设置 name 属性,浏览器就不认为它们属于同一组,导致多个主题同时处于激活状态。
实现主题切换,有几个硬性要求必须遵循:
- 必须用
,不能用checkbox。换肤天然是单选行为,checkbox 的状态不会自动互斥 name属性值必须一致,比如name="theme",让浏览器把它们识别为同一组- radio 必须真实存在于 DOM 中且可交互,不能用
display: none隐藏。推荐用position: absolute; left: -999px;移出可视区 - 样式规则里的
:checked必须能够通过~找到目标节点。比如input#dark:checked ~ :root这种写法,就需要:root是 radio 的后续兄弟元素——实际上,通常我们会借助body或一个外层容器来完成这个联动
如何让 :checked 修改 :root 变量而不依赖 JS?
纯 CSS 做不到直接写 document.documentElement.style.setProperty(),但可以通过属性选择器间接实现覆盖。思路是把不同主题的变量拆成独立的 [data-theme="dark"] :root 规则块,然后用 :checked 控制 body 或根容器的 data-theme 属性值。
关键点在于,整个过程的实质不是“JS 改变量”,而是“JS 切换 class 或 data 属性”,:checked 只负责联动这个动作。真正生效的是 CSS 的层叠优先级——[data-theme="dark"] :root 的权重高于普通 :root,变量自然被覆盖掉。
- 不要在
:root里写多套变量,CSS 不支持条件分支,全部生效会导致变量冲突 - 主题样式必须写在默认
:root规则之后,否则层叠顺序不对,变量不会被替换 - 推荐的结构:
++,然后用input#theme-dark:checked ~ .theme-applier设置data-theme="dark" - 如果用
body[data-theme]做控制,要确保所有var(--color-bg)的引用都在其子元素内,否则继承链会断开
为什么 var(--xxx) 能自动响应 :checked 触发的变量变更?
CSS 变量本质上是级联作用域内的动态值,浏览器渲染引擎每次计算样式时,都会重新从 :root 上读取当前值。只要变量定义的位置没有变(始终在 :root 或更高层选择器下),引用方式统一用 var(--xxx),就不需要 JS 去强制重绘或遍历元素。
这里有一个很容易忽视的硬伤:混用硬编码值。比如某处写了 color: #333,另一处用了 color: var(--text-color),换肤时前者完全不动,视觉上就会出现错乱。
- 所有颜色、间距、阴影、字体大小等可变样式,必须 100% 通过
var(--xxx)引用,不能有例外 - 每个
var()最好带上 fallback,比如color: var(--text-color, #000);,避免变量未定义时样式崩塌 - Shadow DOM 内部需要显式透传变量,用
:host { --color-primary: var(--color-primary); }的方式把外层变量传进来 - 变量名拼写必须严格一致,
--primary-color和--primaryColor是两个不同的变量
移动端和无障碍场景下 :checked 换肤要注意什么?
移动端的点击区域小、焦点管理比较弱,:checked 的效果容易被干扰。屏幕阅读器依赖语义结构,如果隐藏 radio 时处理不当,换肤控件就会变得不可访问。
真实项目里最常见的坑,并不是逻辑写错了,而是 label 的关联失效,或者 focus 样式缺失——用户点击了没反应,或者键盘 Tab 根本进不去。
必须用for显式绑定 radio 的id,不能仅靠包裹结构,否则 Safari 移动端可能不识别- 给
label加padding或min-height来扩大点击区域,至少做到 44×44px,符合 WCAG 规范 - 保留
:focus-visible样式,让用户知道当前焦点在哪个控件上,别用outline: none一刀切 - 不要用
pointer-events: none或user-select: none去拦截 radio 的父容器,这样做会阻断原生的状态更新
坦率地说,:checked 是一种被动响应的机制,它不保证“用户一定能看到变化”。如果变量引用漏了、fallback 写错了,或者 DOM 结构导致选择器链路断开,整个换肤链就会静默失败,连个报错都不会有。所以实现之后,务必拿真机测一轮,特别是移动端 Safari 和键盘导航场景。
