纸上山河 记录技术、思考与生活

深色模式的三种做法与取舍

从媒体查询到 CSS 变量的演进

约 1 分钟 making

深色模式看起来是个小需求,实际上是前端里最容易写出闪烁和闪烁 bug 的地方。核心难点只有一个:首屏不能闪。

做法一:只用媒体查询

@media (prefers-color-scheme: dark) {
  :root {
    --paper: #211c17;
  }
}

优点是零 JS,缺点是用户无法覆盖。想让用户手动切换,还得加一个 class 机制。

做法二:CSS 变量 + 手动切换

把颜色全部收敛成变量,深色模式只覆盖变量:

:root {
  --paper: #f4ecde;
  --ink: #3b3128;
}

:root.dark {
  --paper: #211c17;
  --ink: #f0e6d8;
}

JS 只需要切一个 class:

document.documentElement.classList.toggle('dark');

这套的性价比最高。所有组件只消费变量,永远不需要写 .dark .foo 这种选择器。

首屏闪烁:唯一需要小心的地方

如果 JS 放在 <head> 之后执行,用户会先看到浅色再变深色——那一下闪烁比没有深色模式更糟。解决办法是把这段逻辑内联进 <head>:

<script is:inline>
  const saved = localStorage.getItem('theme');
  const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;
  if (saved === 'dark' || (!saved && prefersDark)) {
    document.documentElement.classList.add('dark');
  }
</script>

这段代码必须同步执行,不能 defer、不能 async。

代码高亮的双主题

这一招比 CSS 变量更巧妙。Shiki 支持一次高亮输出两套颜色:

shikiConfig: {
  themes: { light: 'github-light', dark: 'github-dark' },
  defaultColor: false,
}

输出里每个 token 会带 style="color:#xxx;--shiki-dark:#yyy",然后用 CSS 在暗色下切换:

:root.dark .astro-code,
:root.dark .astro-code span {
  color: var(--shiki-dark) !important;
}

好处是切换主题不需要重新高亮,也不需要加载第二份 CSS。高亮成本仍然只付一次。

三件小事

  • 尊重系统变化:用户没手动选过时,监听 matchMedia 变化自动跟随
  • 尊重动效偏好:prefers-reduced-motion: reduce 下关掉过渡动画
  • 记得 color-scheme:给根元素加上 color-scheme: light dark,滚动条和表单控件才会跟着变深色