深色模式的三个细节

主题切换看起来很简单,但要做得不闪、不刺眼、不违和,有几个容易忽略的地方。

2 分钟阅读 687 字 zero
目录(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');
    }
  });

只有在用户没有手动选择过的情况下才跟随,否则会覆盖用户的意愿。

  • Vibe Coding 全流程提示词规范

    以 AI 为核心驱动的五阶段提示词工作流:从需求方案到代码实现、测试、调试与提交,附可直接复用的 Prompt 模板。

    3 分钟阅读 1265 字
  • 用 Astro 搭建一个静态博客

    从零开始,用 Astro 的内容集合(Content Collections)组织 Markdown,构建期生成静态页面,顺便聊聊为什么静态站点正在回归。

    2 分钟阅读 605 字