怎样实现网站的暗黑模式和主题切换?
颜色全部写成 CSS 变量,按根元素上的 data-theme 切换两套取值;默认跟随 prefers-color-scheme,用户手动选择后存进 localStorage;用 color-scheme 让滚动条和表单控件一起变色;在 head 里用一段同步脚本提前设好主题,避免刷新时闪一下白。
简答
用户把手机系统切成深色,打开你的网站,页面也自动变成深色;页面右上角还有个开关,可以手动在浅色、深色、跟随系统之间切换。这就是暗黑模式要做的事。
做法是把所有颜色写成 CSS 变量,在根元素上挂一个
data-theme属性,浅色和深色各定义一套变量值,切换主题只改这个属性。默认用prefers-color-scheme媒体查询跟随系统;用户手动选过,就把选择存进localStorage,以后优先用它。只改自己的颜色还不够。滚动条、输入框、复选框这些是浏览器画的,要用
color-scheme属性告诉浏览器“这里是深色”,它们才会跟着变。最容易出的问题是闪烁:页面先按浅色画出来,脚本跑完才切到深色,用户看到一下白光。解决办法是在
<head>里放一小段同步脚本,在页面渲染之前就把data-theme设好。
颜色为什么要写成变量
如果颜色直接写在各个组件里,加一套深色就要把每个组件都改一遍。写成变量以后,组件只引用“背景色”“正文色”这样的名字,具体值集中在一处定义。
:root {
color-scheme: light;
--bg: #ffffff;
--text: #1f2328;
--border: #d0d7de;
}
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #0d1117;
--text: #e6edf3;
--border: #30363d;
}
body {
background: var(--bg);
color: var(--text);
}
切换主题时,只要把 <html> 上的 data-theme 改成 dark,所有用到这些变量的地方都会重新计算。变量命名建议按用途起名(--bg、--text),不要按颜色起名(--white),否则深色主题里 --white 的值是黑色,读代码的人会糊涂。变量怎样组织成分层的设计 token,见《设计 token 为什么比散落的颜色变量更适合大型前端项目?》。
color-scheme 管的是哪部分
MDN 列出了 color-scheme 影响的内容:画布表面(页面底色)、滚动条和其他交互界面的默认颜色、表单控件的默认颜色、拼写检查下划线这类浏览器提供的界面。MDN 也写明,其余元素的颜色要作者自己用 prefers-color-scheme 处理。
所以 color-scheme 和 CSS 变量是分工关系:变量管你自己写的颜色,color-scheme 管浏览器画的部分。只写变量不写 color-scheme,深色页面里会出现白底的下拉框和亮色滚动条。
如果网站只跟随系统、不提供手动开关,可以在 :root 上写 color-scheme: light dark,再配合 light-dark() 函数直接写两种颜色,例如 color: light-dark(#1f2328, #e6edf3)。MDN 说明这个函数按当前生效的 color-scheme 返回第一个或第二个值,不需要再写媒体查询。
怎样跟随系统,又允许用户手动选
用户的选择有三种:浅色、深色、跟随系统。存储时只存前两种,跟随系统就删掉存储。
const media = matchMedia('(prefers-color-scheme: dark)');
function applyTheme() {
const saved = localStorage.getItem('theme'); // 'light' | 'dark' | null
const theme = saved ?? (media.matches ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
}
function setTheme(choice) {
if (choice === 'system') localStorage.removeItem('theme');
else localStorage.setItem('theme', choice);
applyTheme();
}
// 系统主题变化时,只有“跟随系统”的用户需要跟着变
media.addEventListener('change', applyTheme);
matchMedia 返回的对象有 matches 表示当前是否匹配,还能监听 change 事件。用户在系统设置里切换深浅色,页面不刷新也能跟着变。因为 applyTheme 里先看 localStorage,手动选过的用户不受系统变化影响。
刷新时为什么会闪一下白
如果上面这段代码放在打包后的 JS 里,执行顺序是:浏览器解析 HTML、应用 CSS、画出浅色页面,然后下载并执行 JS,才把主题改成深色。中间那一下浅色就是闪烁。
改法是把“读取选择、设置属性”这一小段直接内联在 <head> 里,放在样式表之前或紧挨着它。内联的同步脚本会阻塞解析,执行完才继续,所以第一次绘制时 data-theme 已经是正确的值。
<head>
<script>
try {
const saved = localStorage.getItem('theme');
const dark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
} catch {}
</script>
<link rel="stylesheet" href="/app.css" />
</head>
try 是因为某些隐私模式或禁用存储的环境里访问 localStorage 会抛错,抛错时就退回 CSS 的默认主题。这段脚本要尽量短,它阻塞的是首屏。
服务端渲染时还有一种做法:把用户选择存在 Cookie 里,服务端输出 HTML 时直接写好 data-theme,连这段脚本都不需要。但“跟随系统”的用户服务端不知道系统设置,仍然需要脚本或 light-dark() 兜底。
常见追问
图片和图标在深色下怎么处理?
SVG 图标用
currentColor填色,会自动跟着文字颜色变。截图、插画这类位图,可以用<picture>里带media="(prefers-color-scheme: dark)"的<source>换一张深色版本;不过它只看系统设置,用户手动切换时要靠 JS 或 CSS 选择器来换。
切换主题时,颜色过渡动画要不要加?
可以给背景和文字颜色加很短的过渡,但不要对所有元素加
transition: all,切换时大量元素同时动画会卡。还要尊重prefers-reduced-motion,用户要求减少动画时去掉过渡。
深色模式就是把颜色反转吗?
不是。直接用
filter: invert()反转,图片和品牌色都会变怪。深色主题要重新挑颜色:背景用深灰而不是纯黑,文字不用纯白,饱和度高的颜色适当调低,并且重新检查对比度是否达标。