为什么您的深色模式看起来不太对劲?

根据 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 类控制。 这是一个很细微的细节,看起来很简单,但是许多网站并没有注意这一点,导致外观上出现了一定程度的脱节。这种脱节更多地体现在浏览器的原生组件中,例如滚动条或者 Native Select 这类原生 UI 组件

比如 Memos, Memos 是一款很有趣好用开源的自由备忘录软件,前端设计得很现代精致,可惜黑暗模式下侧边栏的滚动条是白色的(见下图),这种割裂的设计顿时会让设计的优雅气质衰减。截至我在用的 0.26.0 版本,这个问题仍未解决。猜想应该是因为 Memos 维护了诸多主题的原因,不过有待考证。

Memos 黑暗模式下滚动条的突兀
Memos 黑暗模式下滚动条的突兀

Updated 2026-04-15

我给 Memos 提了一个 Issue: Dark themes do not set color-scheme, causing native browser UI elements such as scrollbar to stay light · Issue #5839 · usememos/memos。可惜下午满课,手边没有电脑,没法水个 pr。但是这个问题确实在 chore: set native color scheme for dark themes by boojack · Pull Request #5840 · usememos/memos 中解决了,还是很快速的。

哎,不过我似乎并不打算更新 Memos 的版本,所以只为服务后人了...(其实并非,我们可以通过简单的自定义代码解决这个问题,见下面提到的解决方案)

要解决类似的问题,其实只需要让 color-scheme 跟随 .dark 类的变化就行,例如:

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

Updated 2026-04-15 针对上面 Memos 的案例,Memos 的开发者利用 Codex 巧妙的修复了这个问题,首先判断是否是黑暗模式:

const isDarkTheme = (theme: ResolvedTheme): boolean => {
  return theme.endsWith("-dark") || theme.endsWith(".dark");
};

/**
 * Updates the browser native control color scheme to match the current theme.
 */
const updateColorScheme = (theme: ResolvedTheme): void => {
  document.documentElement.style.colorScheme = isDarkTheme(theme) ? "dark" : "light";
};

根据是否是黑暗模式更新 DOM 的 colorScheme,如果是,就设置为 dark,反之为 light

另外,Memos 的前端使用 React Router 构建,它们的 /utils/theme.ts 对于主题加载的函数也很值得学习:

export const loadTheme = (themeName: string): void => {
  const validTheme = validateTheme(themeName);
  injectThemeStyle(resolvedTheme);
  setThemeAttribute(resolvedTheme);
  updateThemeColorMeta(resolvedTheme);
  updateColorScheme(resolvedTheme);
  setStoredTheme(validTheme); // Store original theme preference (not resolved)
};

根据主题按需注入主题 CSS -> 设置主题偏好 -> 更新 Meta 颜色元数据 -> 更新 colorScheme -> 持久化到本地存储,这也是很不错的最佳实践。

当然,如果您使用的是旧版 Memos,仍然可以通过注入自定义脚本来监听并及时更新 color-scheme 状态,这很简单,只需要去 Settings -> System -> Additional script 添加下面的代码就 ok :

(function() {
    const isDarkTheme = (theme) => {
        return theme === "default-dark" || theme === "midnight";
    };

    const updateColorScheme = (theme) => {
        const scheme = (theme && isDarkTheme(theme)) ? "dark" : "light";
        document.documentElement.style.colorScheme = scheme;
    };

    const applyColorScheme = () => {
        const currentTheme = document.documentElement.getAttribute("data-theme");
        updateColorScheme(currentTheme);
    };

    applyColorScheme();


    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            if (mutation.attributeName === "data-theme") {
                applyColorScheme();
                break;
            }
        }
    });
    observer.observe(document.documentElement, { attributes: true });

    window.addEventListener("load", applyColorScheme);

    setTimeout(applyColorScheme, 1000);
})();

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

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