深色模式的三个细节
主题切换看起来很简单,但要做得不闪、不刺眼、不违和,有几个容易忽略的地方。
目录(5 节)
给网站加深色模式,最朴素的实现是两套配色加一个 localStorage。但真正用起来总有哪里不对劲。
细节一:别让页面闪一下
如果主题在 JavaScript 里设置,浏览器会先把默认配色画出来,等脚本执行完再切过去——这就是那一下白闪。
解决办法是把主题判定塞进 <head> 里的同步脚本,在浏览器开始绘制之前执行:
<script is:inline>
try {
var saved = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
</script>
几个要点:
- 必须写在
<head>里,不能等到DOMContentLoaded - Astro 里要用
is:inline,否则脚本会被打包成模块,变成异步执行 - 包一层
try/catch,避免用户禁用存储时整个脚本挂掉
细节二:纯黑背景并不好看
#000000 配 #ffffff 的对比度是 21:1,远超 WCAG 要求的 4.5:1。问题在于它太硬了,长时间阅读会让眼睛发胀。
真实的做法是让深色不是黑,而是带一点冷调的深灰:
:root {
--bg: #0f1115;
--text: #e7e9ee;
--text-muted: #9aa4b5;
}
:root[data-theme='light'] {
--bg: #ffffff;
--text: #17181c;
--text-muted: #5b6472;
}
正文用 #e7e9ee 而不是纯白,对比度大约 14:1,既够清晰又不刺眼。次要信息(日期、字数)再降一档,形成层次。
细节三:代码高亮得跟着换
这一点最容易被漏掉。Shiki 生成代码块时,会在每个 token 上写内联样式:
<pre class="astro-code" style="background-color:#24292e;color:#e1e4e8">
<span style="color:#f97583">const</span>
</pre>
内联样式优先级高于任何选择器,所以简单地写 .dark pre { background: black } 是无效的。
双主题方案能一次解决。在配置里声明两套主题:
export default defineConfig({
markdown: {
shikiConfig: {
themes: { light: 'github-light', dark: 'github-dark' },
},
},
});
Shiki 不会写死颜色,而是输出 CSS 自定义属性:
<span style="--shiki-light:#005cc5;--shiki-dark:#79b8ff">const</span>
然后由你用 CSS 决定用哪一套:
.astro-code span {
color: var(--shiki-dark);
}
:root[data-theme='light'] .astro-code span {
color: var(--shiki-light) !important;
}
注意类名是 .astro-code 而不是 .shiki——Astro 5 之后改掉了。这个改动没有写进任何醒目位置,很容易照着旧教程写出不生效的样式。
顺便说说图片
深色模式下,白底的截图会像一块发光板。可以给图片加一层轻微压暗:
:root:not([data-theme='light']) .prose img {
filter: brightness(0.9) contrast(1.05);
}
别压太狠,0.9 左右就够了,否则细节会糊掉。
别忘了系统偏好
用户第一次访问时,应该跟随系统设置:
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
而且要监听它的变化——用户可能在日落后手动切换了系统主题:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (event) => {
if (!localStorage.getItem('theme')) {
setTheme(event.matches ? 'dark' : 'light');
}
});
只有在用户没有手动选择过的情况下才跟随,否则会覆盖用户的意愿。