前端知库

怎样实现网站的暗黑模式和主题切换?

颜色全部写成 CSS 变量,按根元素上的 data-theme 切换两套取值;默认跟随 prefers-color-scheme,用户手动选择后存进 localStorage;用 color-scheme 让滚动条和表单控件一起变色;在 head 里用一段同步脚本提前设好主题,避免刷新时闪一下白。

参考答案

简答

用户把手机系统切成深色,打开你的网站,页面也自动变成深色;页面右上角还有个开关,可以手动在浅色、深色、跟随系统之间切换。这就是暗黑模式要做的事。

做法是把所有颜色写成 CSS 变量,在根元素上挂一个 data-theme 属性,浅色和深色各定义一套变量值,切换主题只改这个属性。默认用 prefers-color-scheme 媒体查询跟随系统;用户手动选过,就把选择存进 localStorage,以后优先用它。

只改自己的颜色还不够。滚动条、输入框、复选框这些是浏览器画的,要用 color-scheme 属性告诉浏览器“这里是深色”,它们才会跟着变。

最容易出的问题是闪烁:页面先按浅色画出来,脚本跑完才切到深色,用户看到一下白光。解决办法是在 <head> 里放一小段同步脚本,在页面渲染之前就把 data-theme 设好。

颜色为什么要写成变量

如果颜色直接写在各个组件里,加一套深色就要把每个组件都改一遍。写成变量以后,组件只引用“背景色”“正文色”这样的名字,具体值集中在一处定义。

CSS
: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 返回第一个或第二个值,不需要再写媒体查询。

怎样跟随系统,又允许用户手动选

用户的选择有三种:浅色、深色、跟随系统。存储时只存前两种,跟随系统就删掉存储。

JavaScript
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 已经是正确的值。

HTML
<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() 反转,图片和品牌色都会变怪。深色主题要重新挑颜色:背景用深灰而不是纯黑,文字不用纯白,饱和度高的颜色适当调低,并且重新检查对比度是否达标。