为什么你的深色模式滚动条是错的?

根据 MDN 的说法 prefers-color-scheme 能够侦测到用户在操作系统上的主题偏好,也就是浅色和深色模式。

The prefers-color-scheme CSS media feature is used to detect if a user has requested light or dark color themes. A user indicates their preference through an operating system setting (e.g., light or dark mode) or a user agent setting.

通常,我们可以通过媒体查询在写 CSS 的时候控制全局的主题变量。比如,我们可以写出下面的代码:

:root{
/* light mode goes here */
}

@media (prefers-color-scheme: dark) {
  :root{
  /* dark mode goes here */
  }
}

这样,页面主题可以自适应用户的偏好而自动切换。原生组件也可以自适应。例如浏览器的滚动条,您可能没有注意过

通常,我们可以通过设置 color-scheme 来控制可用的主题,例如:color-scheme - CSS | MDN

:root {
  color-scheme: light dark;
}

@media (prefers-color-scheme: light) {
  .element {
    color: black;
    background-color: white;
  }
}

@media (prefers-color-scheme: dark) {
  .element {
    color: white;
    background-color: black;
  }
}

其中 color-scheme: light dark 告诉了浏览器尊重用户的主题偏好来控制主题的显示。举一反三,如果设置 color-schemeonly lightonly dark 就覆盖了用户的主题偏好,转而显示单一主题。

看起来很不错,但是,在实际应用角度上,原生的 CSS 似乎显得有那么力不足。有的时候,我们需要给出一个切换主题的按钮,这个按钮要覆写掉用户原生的主题偏好。

一般的做法是使用一个 .dark 类,通过添加或移除这个类,可以手动的控制页面的外观主题。例如,在 Shadcn UI 中,有这样一行代码:

@custom-variant dark (&:is(.dark *));

对于 Next.js 框架的项目来说,有一个成熟的解决方案是用 next-themes 这个库next-themes - npm,这个库封装好了 ThemeProvider ,只需要包裹一下主要部分就可以轻松使用。然而,我们在使用 React Router v7 或者类似的框架的时候,由于 UI 库本身没有自带 ThemeProvider 或者我们是手搓的 UI 没有封装好的组件,我们似乎不得不自己或者让 LLM 造一个轮子,用于主题的切换。一个常见的思路是用 JS 向 html 标签添加或移除 dark 类:

function toggleTheme() {
  const root = document.documentElement;
  const isDark = root.classList.contains("dark");

  const nextTheme = isDark ? "light" : "dark";

  localStorage.setItem(THEME_KEY, nextTheme);
  applyTheme(nextTheme);
}

聪明一点,我们顺带加上 localStorage 的持久化存储:

const THEME_KEY = "theme";

function applyTheme(theme) {
  const root = document.documentElement;

  if (theme === "dark") {
    root.classList.add("dark");
  } else {
    root.classList.remove("dark");
  }
}

function getSystemTheme() {
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

function initTheme() {
  const saved = localStorage.getItem(THEME_KEY);

  if (saved) {
    applyTheme(saved);
  } else {
    applyTheme(getSystemTheme());
  }
}

然后在 HTML 中渲染一个主题切换按钮:

<button onclick="toggleTheme()">切换主题</button>

相信水了这么多,聪明的读者一定会发现这里的一个明显的漏洞:prefers-color-scheme 在某种程度上是和我们手动添加的 .dark 类是冲突的,尤其是在 color-scheme: light dark; 的情况下 ——我们控制了页面的颜色,但没有控制浏览器自己的 UI。

正确的做法是:如果需要手动控制,就不要用 color-scheme: light dark; 而是将 color-scheme 的变换完全交给.dark 类控制。 这是一个很细微的细节,看起来很简单,但是许多网站并没有注意这一点,导致外观上出现了一定程度的脱节。比如 Memos, Memos 是一款很有趣好用开源的自由备忘录软件,前端设计得很现代精致,可惜黑暗模式下侧边栏的滚动条是白色的,这种割裂的设计顿时会让设计的优雅气质衰减。截至我在用的 0.26.0 版本,这个问题仍未解决。猜想应该是因为 Memos 维护了诸多主题的原因,不过有待考证。

例如:

:root {
  color-scheme: light;
}
:root.dark {
  color-scheme: dark;
}

在某种程度上,prefers-color-schemecolor-scheme: light dark; 仍然是最优解,因为我们可以完全依靠浏览器的原生能力而不是繁杂的 JavaScript 脚本来控制这个极其细微的细节。一般来说,用户使用了深色主题,它们在访问网站的时候,往往需要的是一个同样舒服的深色主题,而不是选择手动切换到可能亮瞎眼的浅色模式。某种程度上,如果不是想 Memos 那样维护其它例如 Paper 这样的暖色主题(区分于 light/dark 模式),主题切换按钮往往也是没有必要的、甚至是过度设计的(over-designed)。

所以,下次设计用户界面的时候,不妨先思考一下:用户真的需要手动切换主题吗?或者说,您认为用户反复的手动切换主题对它们来说也算是一种乐趣。如果是后者,那么设计一个按钮、维护一些 JavaScript 也无可厚非,但是如果用户觉得这没必要,那还是删除比较好,毕竟,少即是多嘛。