用 CSS 变量管理深浅色主题

给站点加深浅色切换,我前后改过三版。第一版写了两套完整的样式表,第二版用 prefers-color-scheme 媒体查询硬套,最后落到现在的方案:把颜色收进 CSS 变量,用 HTML 上的一个属性控制切换。

三种做法的问题

写两套样式表的问题是维护成本翻倍。改一个间距要改两个地方,时间一长必然漏掉一处,然后在某个深夜收到一条“深色模式下这里看不清”的留言。

只用媒体查询的问题是用户没法手动选。系统是深色,他就只能看深色,哪怕白天在阳光下看不清楚。

现在的做法

第一步,把所有颜色定义成变量,浅色放 :root,深色挂在 [data-theme='dark'] 上:

:root {
  color-scheme: light;
  --bg: #ffffff;
  --text: #18181b;
  --border: #e8e8ea;
}

[data-theme='dark'] {
  color-scheme: dark;
  --bg: #0f0f11;
  --text: #f0f0f2;
  --border: #26262b;
}

第二步,样式里只用变量,不写死颜色:

body {
  background-color: var(--bg);
  color: var(--text);
}

.card {
  border: 1px solid var(--border);
}

这里有个容易被忽略的细节:color-scheme 不只是给浏览器看的提示。它决定滚动条、表单控件、输入框光标这些浏览器自己绘制的东西用浅色还是深色。漏掉这一行,深色页面里会出现一条刺眼的白色滚动条。

别让页面闪一下

切换逻辑本身很简单,真正麻烦的是首屏闪白:浏览器先按默认浅色渲染了一遍,脚本执行后才改成深色,用户会看到一次明显的白闪。

解决办法是把这段判断放在 <head> 的最前面,并且让它内联执行、不要被打包工具延迟:

<script is:inline>
  (function () {
    var theme = 'light';
    try {
      var saved = localStorage.getItem('theme');
      if (saved === 'light' || saved === 'dark') {
        theme = saved;
      } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        theme = 'dark';
      }
    } catch (error) {
      /* 隐私模式下 localStorage 可能抛错,退回浅色 */
    }
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

判断的优先级是:用户手动选过的 > 系统设置。前者存进 localStorage,后者读 matchMedia。

跟随系统变化

只在加载时读一次系统设置是不够的。macOS 和 Windows 都支持按时间自动切换深色,如果用户没手动选过,页面应该跟着变:

media.addEventListener('change', (event) => {
  // 用户手动选过就不打断他
  if (localStorage.getItem('theme')) return;
  applyTheme(event.matches ? 'dark' : 'light');
});

几个小细节

  • 切换按钮的图标用两个 SVG 叠加,靠 CSS 按当前主题显示其中一个,避免图标闪一下
  • 主题色记得同步给 <meta name="theme-color">,手机浏览器地址栏颜色才会跟着变
  • 过渡动画包在 prefers-reduced-motion: no-preference 里,照顾对动效敏感的人

整套改下来,样式表里没有一个写死的颜色,加一套新主题只是再写一组变量的事。