深色模式的三种做法与取舍
从媒体查询到 CSS 变量的演进
深色模式看起来是个小需求,实际上是前端里最容易写出闪烁和闪烁 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,滚动条和表单控件才会跟着变深色