首页 / 文章

深色模式九种实现技术对比:从滤镜技巧到服务器Cookie

对九种为网页应用添加深色模式的方法进行比较,这些方法包括反相滤镜、令牌机制、light-dark()函数以及服务器Cookie,并了解每种方法会悄无声息地引入哪些漏洞。

发布者:REACTAPP.TOP阅读约 24 分钟5596 词

来源: Dark Mode on the Web, Ranked: 9 Ways to Theme a Page and What Each One Actually Breaks

深色模式通常被视为一种临时解决方案与真正解决方案之间的选择,但实际上网站在实现它时至少会使用九种不同的技术,而每种技术只能解决部分问题。那些被技术忽略的问题往往会在之后表现出来,比如页面闪烁、固定标题损坏,或是颜色始终无法改变。本指南对这些九种方法进行了排序,分析了每种方法的优缺点,同时还探讨了即便在精心实施时也容易出错的细节:错误主题的显示、color-scheme属性、三态偏好设置、过渡效果、嵌入内容以及调色板设计。

以下关于行为特性的描述均基于第一手资料:CSS工作组草案、WHATWG HTML标准、MDN背后可供机器读取的browser-compat-data数据、基准状态数据以及next-themes库的源代码,并通过无头Chromium进行了交叉验证。两种普遍存在的观点在如此严格的检验下并不成立,而某种能够捕获position: fixed子元素的filter特性,实际上比通常所提到的那些模糊的性能考量更足以成为避免使用反转技巧的理由。

深色模式涉及三个独立问题

“添加深色主题”听起来似乎只是单一任务。但实际上它包含了三个相互独立的问题,即便你完美地回答了其中一个问题,仍可能推出有缺陷的实现:

  1. 应显示哪种主题?操作系统有默认设置,用户可能希望覆盖该设置,同时也需要能够恢复使用系统默认设置。简单的开关功能会永久失去这第三种选择。
  2. 颜色如何变化?单个开关必须更新所有的背景、边框、图标和阴影,而其位置实际上决定了CSS架构的构成。
  3. 主题何时应用?如果在首次渲染之后才做出决定,用户将看到页面颜色在他们眼前发生变化。

以下每种技术都只是解决了这些问题中的一部分。规律很明显:所谓的“延迟加载”方法通常只单独处理第二个问题,而完全忽略第一个和第三个问题。

如何解读排名

排名前三的方案并非相互竞争的关系,而是整合为一种统一的实现方式:语义标记是基础,light-dark()则是更简洁的标记书写方式,而通过服务器读取的cookie则能在无需Flash的情况下传递选定的主题。排名第四至第六的方案则是真正的取舍选项,需要从中挑选一种。排名第七至第九的则属于技术债务。

第9名:filter实现整页颜色反转

这种最简化的深色模式会对根元素应用颜色反转和色调旋转效果:

html {
  filter: invert(1) hue-rotate(180deg);
}

它还会立即触发另一条规则,对所有媒体内容再次进行颜色反转,从而使照片和视频恢复正常显示:

/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
  filter: invert(1) hue-rotate(180deg);
}

人们通常提出的反对意见是性能问题,但这一点很难有明确的证据。其实还有更重要的问题。MDN关于包含块的文档中明确指出:如果将filter设置为除none以外的值,该元素就会变成那些具有position: fixed和position: absolute属性的子元素的包含块。同时,这还会创建一个新的堆叠上下文,从而悄悄改变其下方所有元素的z-index值计算方式。

通过无头测试可以具体验证这一点。在Chromium 141版本的3000像素高的页面上,将一个固定定位的条状元素放入经过过滤处理的容器中,向下滚动400像素后,测量该条状元素的实际位置:

await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.

该插件显示的视口偏移量为100而非0,因此不再固定;它会随内容一起滚动。当应用于html时,该过滤器会破坏页面上所有的固定标题、固定导航、模态覆盖层、提示信息堆以及抽屉式组件。这是一个只需几行代码就能复现的正确性缺陷,而非个人偏好问题。

其余的问题也很常见:

  • 每个光栅资源都需要进行反向处理,而那些内置了品牌颜色的标志仍然显示错误,因为在任何颜色空间中都将色相旋转180度都无法实现精确的反向转换。
  • 品牌颜色会变成其数学上的对立色,而非设计好的深色调色板。
  • 没有权威指南推荐这种方法。web.dev关于深色模式的指导从未建议整体反转页面;对于媒体内容,它建议对照片使用filter: grayscale(50%),而仅对单色图标使用invert(100%)。
  • Chrome自身关于自动反转功能的建议是创建精心设计的深色主题,而非选择关闭该功能。
  • 第8名和第7名:暂时有效但最终会失效的方法

    逐组件覆盖

    为每个组件编写深色版本是自然而然的首选尝试。这并非完全错误,只是缺乏限制。首先从浅色样式开始:

    .card       { background: #fff; color: #14161a; }
    .card .btn  { background: #f0f2f5; }
    

    然后在body类下添加对应的深色样式:

    body.dark .card            { background: #121212; color: #fff; }
    body.dark .card .btn       { background: #333; }
    body.dark .card .btn:hover { background: #444; }
    

    由于深色规则必须优于浅色规则,选择器变得越来越具体;body.dark .card .btn:hover从一开始就包含了四个部分。十六进制颜色值也各不相同,某个文件中使用的是#121212,另一个文件是#111,而复制的组件中则是#0f0f0f,且没有统一的机制来自动检测对比度。核心的扩展问题可以用一句话概括:随着组件数量的增加,覆盖规则也会增多;而随着角色数量的增加,颜色标记也会增多,大多数设计系统中的角色数量大约在12到20个之间。

    两个独立的样式表

    通过媒体查询加载浅色和深色样式表看似效率很高:

    <link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
    <link rel="stylesheet" href="dark.css"  media="(prefers-color-scheme: dark)">
    

    该方法无法避免第二次下载,而这正是人们常犯的错误。正如web.dev上关于prefers-color-scheme的文章所解释的,那些媒体查询条件不匹配的样式表仍会被加载,只是优先级最低,因此无法与页面当前所需的资源竞争。这样做的好处是缩短了关键路径长度,而非减少数据量。此外,media属性仅读取操作系统的设置,因此任何手动切换都无法影响它;随着时间推移,这两个文件的内容往往会出现差异;而且如Vite的某个问题记录所示,打包工具也可能处理不当。

    第6名::has()实现的纯CSS切换功能

    一个视觉上隐藏的复选框及其标签就可以充当切换开关:

    <input type="checkbox" id="theme" class="sr-only">
    <label for="theme">Dark mode</label>
    

    父元素通过 :has() 对复选框的状态作出响应:

    html:has(#theme:checked) {
      color-scheme: dark;
      --bg-surface: #1b1f27;
      --text-1:     #e8e6e3;
      --border:     #2b313c;
    }
    

    实际上根本不需要 JavaScript,而且 :has() 在 2026-06-19 已达到“广泛可用”的基准水平。有两点需要注意:首先,要像示例那样同时更改颜色值和 color-scheme;其次,状态仅存在于 DOM 中,因此每次页面加载都会从头开始,没有任何数据会被保存。它也无法整齐地表示三种状态——那样就需要使用单选按钮以及更多的选择器分支。它适合用于演示、CodePen 或单页文档,但不适合作为产品功能。

    第5名:Tailwind 的 dark: 变体

    dark: 变体本身并无错误,只是将颜色决策放在了错误的位置:即在模板中,并且在每次调用时都会重复处理。

    <div class="bg-white dark:bg-zinc-900
                text-zinc-900 dark:text-zinc-100
                border-zinc-200 dark:border-zinc-800
                hover:bg-zinc-50 dark:hover:bg-zinc-800">
    

    这样会导致类名字符串变得过长且难以管理,同时由于深色值分散在各个模板中,没有任何脚本能够对其进行审核。此外,使用高对比度之类的额外主题或品牌皮肤只会增加标记层而非真正添加新的样式层,对浏览器自带的UI也没有任何改善,仍然需要单独的脚本来避免闪烁现象。

    解决方案就在Tailwind本身。将主题颜色映射为CSS变量并只需修改一次这些变量,之后就可以直接使用bg-surface而无需加上dark:前缀。首先使用标准的导入方式:

    @import "tailwindcss";
    

    接着定义自定义变体,将主题颜色关联到相应变量,并为每个主题设置独立的变量值:

    @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
      --color-surface: var(--surface);
      --color-content: var(--content);
    }:root               { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
    [data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
    [data-theme="hc"]   { --surface: #000000; --content: #ffffff; color-scheme: dark; }
    

    :where()包装器是刻意设计的。它让深色主题没有额外的特异性,因此dark:样式规则永远不会意外覆盖无关的样式。采用高对比度主题只需多一行代码,而无需遍历所有模板。

    第4名:仅遵循prefers-color-scheme设置

    最简单且可靠的方案是默认定义浅色主题标识,然后在媒体查询中重新定义它们:

    :root {
      color-scheme: light;
      --bg-base: #ffffff;  --text-1: #14161a;
    }
    
    @media (prefers-color-scheme: dark) {
      :root {
        color-scheme: dark;
        --bg-base: #12141a;  --text-1: #e8e6e3;
      }
    }
    

    无需JavaScript,没有Flash元素,也没有需要初始化的内容。这是整个列表中最快的方法,其唯一的缺点对于应用程序来说至关重要,但对于内容而言则无关紧要:用户无法覆盖系统设置。

    如果像博客、文档、变更日志和营销页面那样,从来没有人要求过切换功能,那么就到此为止吧。对于这类网站而言,列表中的其他选项都比不上它。

    有两个规范细节值得了解:

    • Media Queries Level 5 警告称,该功能未来可能会增加更多取值,棕褐色就是一个例子,并建议通过否定方式进行测试:使用 (prefers-color-scheme: dark) 对比 (not (prefers-color-scheme: dark)),而非直接匹配 light。
    • no-preference 取值已被移除。它既不在当前规范中,也没有浏览器实现该功能;没有设定偏好的用户将匹配 light。

    第3名:light-dark() 及其隐性失败模式

    light-dark() 能将令牌文件的大小大致减半,因为单个声明即可同时包含两种颜色值:

    :root { color-scheme: light dark; }   /* REQUIRED */
    
    .card {
      background:   #fff;                          /* fallback for old browsers */
      background:   light-dark(#fff, #1b1f27);
      color:        light-dark(#14161a, #e8e6e3);
      border-color: light-dark(#e2e5ea, #2b313c);
    }
    /* a manual override becomes ONE property write */
    [data-theme="dark"]  { color-scheme: dark; }
    [data-theme="light"] { color-scheme: light; }
    

    这其实是一个完整的主题系统,无需任何 @media 块或额外的 :root 规则。若需手动覆盖,只需在根元素上设置 color-scheme 即可。

    但问题在于这会耗费团队数小时的时间。在 Chromium 141 中分别测试强制使用两种颜色方案的多种版本后,发现了三个重要问题:

    1. 如果没有 color-scheme,light-dark() 将不会起任何作用。在深色主题系统中,它仍会返回浅色值,控制台也不会显示任何异常提示。这是 light-dark() 最常见的故障,往往要等到有人在深色系统上报告问题才会被发现。
  • 在元素上设置 color-scheme: dark 后,无论操作系统如何设定,该元素都会采用第二个参数指定的颜色方案。因此手动切换只需修改一个属性值,而不需要更换类名并同时添加一系列规则。
  • 在非根元素上设置 color-scheme: dark 并不会使其背景变为深色。该设置只会改变系统颜色和原生控件的外观,而画布背景仍会跟随根元素的颜色。这是人们对这一属性最普遍的误解。
  • 作为对自己颜色方案的验证,Chromium 在深色模式下会将 Canvas 的系统颜色计算为 rgb(18, 18, 18),即 #121212,这与 Material 设计指南中为深色主题推荐的背景颜色相同。

    在依赖这一规则之前,请先考虑以下注意事项:

    • 它目前处于“新可用”阶段而非“广泛可用”阶段:Chrome和Edge 123、Firefox 120以及Safari 17.5已支持该特性,其新可用日期为2024-05-13,因此“广泛可用”的时间点大概在2026-11-13左右。
    • 该特性无法实现平滑降级。不支持它的浏览器会将整个声明视为无效而直接忽略,因此务必像示例那样先为同一属性提供常规的回退方案。
    • 这是一个颜色值,因此不能用作媒体查询条件。在@media块内的声明中使用它是可以的,但这属于另一回事。
    • 根据撰写时的兼容性数据,类似light-dark(url(a.png), url(b.png))这样的图像参数功能仅在Chrome 150、Firefox 150和Safari 27中支持,但该数据时间过近,不可完全依赖。

    一个文档相关提示:MDN关于light-dark()的说明页面中列出的兼容版本为Chrome 119和Safari 17.2,而MDN自身的browser-compat-data数据以及Baseline API显示的版本则为123和17.5。当两者数据不一致时,应以结构化数据为准,在引用具体版本号之前请先查看相关页面。

    第二位:语义标记,其他所有元素所依赖的基础

    核心原则是为颜色所承担的角色命名,而非直接使用颜色名称本身。应从最基础的原始调色板值开始,这些值是组件永远不会直接引用的:

    /* primitives: raw values, never consumed by components */
    :root {
      --gray-0: #ffffff;  --gray-50: #f4f5f7;  --gray-200: #e2e5ea;
      --gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
      --blue-500: #3b82f6; --blue-400: #60a5fa;
    }
    

    在其之上还有一层语义层。浅色为默认值,一个[data-theme="dark"]标签即可替代所有浅色设置,而且组件仅需要使用角色名称,因此无需知晓当前处于哪种主题模式:

    /* semantic roles: light is the default */
    :root {
      color-scheme: light;
      --bg-base:    var(--gray-0);
      --bg-surface: var(--gray-50);
      --text-1:     var(--gray-900);
      --text-2:     var(--gray-600);
      --border:     var(--gray-200);
      --accent:     var(--blue-500);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.08);
    }/* one block flips the whole app */
    [data-theme="dark"] {
      color-scheme: dark;
      --bg-base:    #12141a;   /* grey, not #000 */
      --bg-surface: #1b1f27;   /* lighter = higher up */
      --bg-raised:  #232833;   /* lighter still */
      --text-1:     #e8e6e3;
      --text-2:     #a2acbb;
      --border:     #2b313c;
      --accent:     var(--blue-400);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.5);
    }/* components never know which theme is active */
    .card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
    

    注意深色区块中的细节:基底为深灰色而非黑色,位置越高的表面颜色越浅,强调色也会变为更浅的色调,而阴影则变得更浓以保持可见性。

    一个简单的测试就能判断某个标记名称是否合适:能否在不提及颜色的情况下描述其使用场景?“凸起面板的背景”描述的是一种角色,“浅灰色”则描述的是一种颜色样例。只有角色能在主题切换后依然存在,因为名为“浅灰色”的标记绝不应变成深色。

    对于开关本身而言,使用 data-theme 属性比 .dark 类更佳。该属性可自然地存储三个或更多值,不会与其他实用类产生冲突,且只需通过一次赋值即可修改 document.documentElement.dataset.theme 的值。如需了解更多关于在运行时使用自定义属性的技巧,请参阅博客中关于使用 CSS 自定义属性实现主题切换的指南。

    在服务器端渲染主题是唯一能避免四种常见问题的方法:内联脚本、页面闪烁、数据同步错误以及内容安全策略异常,因为服务器在发送第一个字节之前就已经知晓主题信息。在 Next.js App Router 项目中,根布局会导入 Cookie 处理工具:

    // app/layout.tsx
    import { cookies } from 'next/headers';
    

    并将存储的主题直接写入html元素,若无法设置则回退为浅色主题:

    export default async function RootLayout({ children }) {
      const store = await cookies();                 // async since Next 15
      const theme = store.get('theme')?.value ?? 'light';  return (
        <html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
          <body>{children}</body>
        </html>
      );
    }
    

    主题的切换是在服务器端操作中完成的,该操作也需要相同的导入:

    // app/actions.ts
    'use server';
    import { cookies } from 'next/headers';
    

    此操作会将选择结果保存一年,其作用范围覆盖整个网站:

    export async function setTheme(theme: 'light' | 'dark') {
      const store = await cookies();
      store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
    }
    

    相关成本由Next.js本身记载如下:

    • cookies()是请求时调用的API,因此在布局或页面中调用它会导致该路由进入动态渲染模式,从而失去静态预渲染功能。
    • 当启用了缓存组件后,在<Suspense>作用域之外调用cookies()也会阻止预渲染。
    • HTTP协议不允许在数据流开始传输后设置cookie,因此必须通过服务器函数或路由处理程序中的.set方法来写入cookie,绝不能在渲染过程中操作。
  • 每个请求都会携带该cookie,大约增加15字节。
  • 操作系统设置对服务器来说是不可见的,因此system类型的选择仍需在客户端处理。实际可行的方案是:明确的选择使用cookie,而系统自动检测则使用matchMedia。
  • 存在一个客户端提示字段Sec-CH-Prefers-Color-Scheme,可用于向服务器传达操作系统的偏好设置。它目前仅是WICG提出的草案,仅存在于Chromium中,并非标准规范,因此最多只能视为一种优化手段,而非通用解决方案。

    避免错误主题的突然显示

    这是从问题开始算起的第三个问题,也是已上线网站中最常见的深色模式缺陷。“正确做法与偷懒做法”的争论通常根本不会提及它。时间线展示了为何延迟加载的脚本会为时已晚:

    DEFERRED SCRIPT:  [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK]   <- user sees it
    BLOCKING INLINE:  [HTML][JS][CSS][PAINT: DARK]                   <- correct first paint
    

    主题必须在首次绘制之前设置在 html 元素上。解决办法是在 head 中添加一段简短的内联脚本,将其置于样式表之前:

    <head>
      <meta charset="utf-8">
      <meta name="color-scheme" content="light dark">
      <script>
        // inline. no src, no defer, no async, no type=module.
        (function () {
          try {
            var s = localStorage.getItem('theme');   // 'light'|'dark'|'system'|null
            var dark = s === 'dark' ||
              ((!s || s === 'system') &&
               matchMedia('(prefers-color-scheme: dark)').matches);
            var el = document.documentElement;
            el.dataset.theme     = dark ? 'dark' : 'light';
            el.style.colorScheme = dark ? 'dark' : 'light';
          } catch (e) { /* storage throws in private mode / sandboxed iframes */ }
        })();
      </script>
      <link rel="stylesheet" href="/app.css">
    </head>
    

    该代码片段中的每一项约束都很重要。脚本必须是内联的且同步执行的,不能使用src、defer、async或模块形式,因为这些特性都会让浏览器先进行渲染。它读取一个三值偏好设置,并通过matchMedia来确定system值。同时它会设置相关属性和colorScheme,以确保令牌与浏览器界面保持一致。此外,它还将对存储操作的访问包裹在try/catch结构中,因为在隐私浏览模式或沙箱化iframe环境中localStorage会抛出异常。

    真正的代价在于安全策略:内联脚本需要在CSP中设置'unsafe-inline'或使用一次性随机值。如果策略不允许这两种方式,则应采用Cookie方案。

    在 Next.js 中,你还需要在 html 元素上使用 suppressHydrationWarning,因为在 React 进行数据同步之前脚本会修改其属性,导致这些属性与服务器端的标记不再匹配。正如 next-themes 的文档所述,该标志仅对当前层级有效,因此无法隐藏其他地方的同步警告。

    查看 next-themes 的源代码可知,其在防止闪屏方面的实现相当简单。它会渲染一个 <script dangerouslySetInnerHTML> 元素,其内容为自身的 script() 函数,该函数通过 script.toString() 转换为字符串后,立即使用 JSON 序列化的参数被调用。其 useTheme() 钩子会返回 theme、setTheme、resolvedTheme、systemTheme 以及 themes,而在服务器端渲染时 theme 的值为 undefined。应在进行组件挂载检查之后,根据 resolvedTheme 来渲染切换按钮,否则该按钮本身就会导致水合错误。

    color-scheme:大多数网站从未设置过的属性

    你的样式表负责渲染你编写的内容,但浏览器本身会绘制滚动条、原生表单控件以及页面背后的画布。CSS颜色调整规范要求用户代理将所有这些元素与元素的色彩方案保持一致:

    • 滚动条和交互式用户界面的默认颜色
    • 表单控件的默认外观
    • 额外的浏览器用户界面元素,例如拼写检查下的下划线
    • 系统颜色,如Canvas、CanvasText、ButtonFace、Field和AccentColor
    • light-dark()函数的计算结果

    在根元素上,该方案还负责控制画布表面颜色以及视口滚动条。需在三个位置进行声明:首先是在任何CSS代码之前,HTML解析器会先读取的元标签:

    <!-- parsed at HTML-parse time, BEFORE any CSS loads -->
    <meta name="color-scheme" content="light dark">
    

    其次是通过CSS规则,使该属性与主题属性保持同步:

    :root               { color-scheme: light dark; }
    [data-theme="dark"] { color-scheme: dark; }
    [data-theme="light"]{ color-scheme: light; }
    

    最后,在需要时通过锁定机制,确保特定控件在任何主题下都保持浅色外观:

    /* force a widget to stay light regardless */
    .brand-widget       { color-scheme: only light; }
    

    该元标签并非多余。HTML标准中有关颜色方案元标签的章节正是为了让浏览器能够立即用正确的颜色方案渲染页面背景,而无需等待样式表加载。CSS属性则要等到样式表下载并解析完成后才会被识别;这段时间会导致页面背景暂时显示为白色。标准还规定每个文档中最多只能存在一个此类元元素。

    还有两个需要注意的问题:

    • 该属性与媒体查询并无关联。声明color-scheme: dark绝不会使prefers-color-scheme: dark生效,因此试图通过其中一个属性推断另一个属性的代码将会出现错误。
  • only 关键字用于告知浏览器不得覆盖该元素的方案。实际上,它就是让 Android 版 Chrome 无法应用其自动深色主题的机制。该功能的兼容性历史颇为特殊:在 Chrome 81 中加入,85 版中被移除,又在 98 版中恢复。
  • 三种状态而非布尔值

    一旦该选项变为布尔值,“跟随我的操作系统”选项就会消失,且用户无法重新启用它。此偏好设置需要三种值:浅色、深色和系统默认。首先需定义一个存储键以及一组媒体查询规则:

    const STORAGE_KEY = 'theme';
    const mq = matchMedia('(prefers-color-scheme: dark)');
    

    其余逻辑会应用该偏好设置并保存它,以 system 作为默认值读取回来,并且仅在选择了 system 时才持续监听操作系统的变化:

    function apply(pref) {                     // 'light' | 'dark' | 'system'
      const dark = pref === 'dark' || (pref === 'system' && mq.matches);
      const el = document.documentElement;
      el.dataset.theme     = dark ? 'dark' : 'light';
      el.style.colorScheme = dark ? 'dark' : 'light';
    }function setPreference(pref) {
      try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
      apply(pref);
    }function getPreference() {
      try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
      catch (e) { return 'system'; }
    }// keep following the OS, but ONLY while 'system' is the chosen preference
    mq.addEventListener('change', () => {
      if (getPreference() === 'system') apply('system');
    });apply(getPreference());
    

    应在 MediaQueryList 上使用 addEventListener,而非 addListener。由于 MediaQueryList 现已继承自 EventTarget,addListener 和 removeListener 已被弃用,尽管网上仍有许多代码片段仍在使用它们。next-themes 故意保留了这些弃用方法,并在代码注释中说明了原因,以便兼容旧版本的 Safari。

    该控件应为一组三个单选按钮,而非复选框,因为三种状态需要三个输入项。

    避免切换主题时出现颜色模糊

    如果页面上的元素具有颜色过渡效果,切换主题时会同时触发数百个属性的动画,导致页面出现模糊现象。next-themes在其disableAnimation选项中采用的解决方案是注入一段临时样式,以关闭所有过渡效果:

    function disableTransitionsTemporarily(nonce) {
      const css = document.createElement('style');
      if (nonce) css.setAttribute('nonce', nonce);
      css.appendChild(document.createTextNode(
        `*,*::before,*::after{ transition: none !important }`
      ));
      document.head.appendChild(css);
    

    该选项会返回一个用于恢复过渡效果的函数,同时还有一个swapTheme辅助函数负责处理两次主题切换之间的过渡:

      return () => {
        // Deliberate forced synchronous reflow: commit the new colours
        // WHILE transitions are still off.
        (() => window.getComputedStyle(document.body))();
        // Remove on a later task, after the flush has committed.
        setTimeout(() => { document.head.removeChild(css); }, 1);
      };
    }function swapTheme(next) {
      const enable = disableTransitionsTemporarily();
      apply(next);
      enable();
    }
    

    getComputedStyle的调用看似是无效代码,常常会被删除。实际上它是故意设计的强制同步样式刷新机制,在过渡效果仍被禁用时即可应用新的颜色。之后会通过setTimeout将删除操作推迟到刷新生效之后的某个时间点执行。如果没有这种强制重新应用样式和延迟删除的操作,仍然会出现部分渐变效果。

    如果希望切换效果更加明显,View Transitions API 支持常见的圆形渐显效果。该功能自 2025-10-14 起在 Baseline 版本中可用,已支持 Chrome 111、Safari 18 和 Firefox 144。有一个容易被忽略的要求:需禁用根快照的默认动画,并为旧快照设置混合模式:

    ::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
    ::view-transition-new(root) { animation: none; }
    

    同时也要尊重那些希望减少动画效果的用户:

    @media (prefers-reduced-motion) {
      ::view-transition-group(*),
      ::view-transition-old(*),
      ::view-transition-new(*) { animation: none !important; }
    }
    

    不要从旧快照中移除 mix-blend-mode: normal。在默认的 plus-lighter 混合模式下,从深色切换到亮色时屏幕会短暂变成乳白色,这看起来像是闪光故障,也会被报告为此类问题。

    令牌切换后仍存在问题的部分

    图片与 SVG

    带有media="(prefers-color-scheme: dark)"的<picture>元素仅会遵循操作系统的设置。它无法识别用户的主题属性或color-scheme,因此手动切换会导致图片与界面其他部分不同步,这是常见的缺陷。在深色模式代码块中声明的背景图片则会随切换而改变。对于SVG,内联<svg>及<svg><use href="…">结构中的currentColor属性有效,但通过<img src>或CSS url()加载的SVG则无效,因为这些是独立的文档,不会继承用户的color设置。要么在SVG文件本身中加入prefers-color-scheme查询,要么结合使用mask-image与background-color: currentColor。

    Iframes

    颜色调整规范规定,当 iframe 的配色方案与嵌入文档的根元素不同步时,浏览器必须在嵌入文档的 Canvas 中绘制不透明的画布而非透明画布。实际上这会在深色页面上显示一个白色矩形。为 <iframe> 元素设置 color-scheme 属性可以修正第一个画布的问题,但跨源文档自身的 CSS 仍然无法被访问。YouTube、Stripe Elements、Disqus 和 Turnstile 的嵌入功能各自都提供了独立的主题选项;目前尚无 CSS 层面的解决方案。

    theme-color

    theme-color 元标签的支持程度远低于人们的预期。Firefox 在任何平台上都不支持该功能;桌面版 Chrome 从 73 版开始仅对已安装的 PWA 应用该设置;Safari 在 15 版中采用了该功能,但从 26 版起仅对已安装的网页应用生效。MDN 将其列为支持程度有限,而非基础标准。该规范还允许浏览器根据自身判断调整颜色,例如通过加深颜色来保持足够的对比度,因此不可依赖完全一致的渲染效果。

    强制颜色与对比度设置

    在 Windows 高对比度模式(forced-colors: active)下,浏览器会控制包括 background-color、color、border-color、outline-color、text-decoration-color 以及 SVG 的 fill 和 stroke 在内的各种属性。box-shadow 和 text-shadow 都会被重置为 none,而 color-scheme 则固定为 light dark。任何依赖阴影效果的立体感都会消失,因此应使用系统颜色边框来替代。

    @media (forced-colors: active) {
      /* your shadow-based elevation is gone — replace it */
      .card { border: 1px solid CanvasText; box-shadow: none; }
      .btn  { border: 1px solid ButtonText; }
    }
    

    元素所使用的系统颜色取决于其本身的 HTML 语义,而非 ARIA 角色,因此 <div role="button"> 并不会获得 ButtonText 属性。MDN 明确指出,不应为强制颜色模式单独设计样式,只需进行微小的可读性调整即可。

    还有一个常被完全忽视的属性:prefers-contrast,其取值包括no-preference、more、less和custom。Baseline版本自2022-05-31起广泛可用,且与颜色方案无关。那些从不选择more值的深色主题常常存在无障碍设计缺陷。

    设计深色调色板

    使用深灰色而非黑色

    Material设计指南建议在深色背景和表面使用深灰色而非黑色,因为灰色能让阴影保持可见,同时减少浅色文字带来的视觉疲劳。Google的深色主题实验还提到了一点:在深色背景上使用纯#FFFFFF颜色的文字可能会出现渗色或模糊现象,甚至看起来有振动感,从而影响可读性。

    需谨慎表述这一点。所谓纯黑色会导致“光晕”效应的论调屡见不鲜,但似乎并无任何对照实验予以支持。得到证实的是纯白色文字的渗色与振动效应,而选择灰色而非黑色则是出于阴影可见度及减轻眼睛疲劳的考虑。

    用明度表现层次感

    在深色主题中阴影效果不佳,因此 Material 设计通过让表面越靠上颜色越浅、色彩也更为丰富来弥补这一缺陷。color-mix()功能可让这一效果轻松从单一基础色中实现:

    [data-theme="dark"] {
      --surface-1: #12141a;
      --surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
      --surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
      --surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
    }
    

    color-mix() 自 2025-11-09 起已在 Baseline 版本中广泛可用。需注意,Material Design 2 的 elevation-overlay 系统已过时:Google 的文档指出这些叠加效果已被色调表面颜色系统取代,且不再维护。Material 3 使用从 surfaceContainerLowest 到 surfaceContainerHighest 的角色,以及 surfaceDim 和 surfaceBright。那些提供从 1dp 对应 5% 颜色到 24dp 对应 16% 颜色的旧数值表的参考资料,其实引用的是已过时的系统。

    降低强调色的饱和度

    饱和的中色调点缀在深色背景上显得格外醒目。OKLCH使这种调整更加系统化:其明度通道在感知上具有均匀性,因此数值上相等的步长在视觉上也会显得均匀,而这正是HSL所欠缺的,也是为何HSL在渐变到深色时中间部分会变得浑浊的原因。为深色主题设计的更浅、色彩较少的点缀效果如下:

    :root               { --accent: oklch(0.55 0.18 255); }  /* darker tone on light bg */
    [data-theme="dark"] { --accent: oklch(0.72 0.14 255); }  /* lighter, less chroma */
    

    再次检查深色主题的对比度

    仅在亮色模式下通过对比度测试的调色板并不能说明其在暗色模式下的表现。WCAG 2.x的相对亮度公式足够简短,可以直接写在脚本中:

    const lum = ([r, g, b]) => {
      const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
      return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
    };
    

    对比度比率是通过将较亮的亮度值除以较暗的亮度值来计算的,两者均会减去0.05作为偏移量:

    const contrast = (a, b) => {
      const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
      return (hi + 0.05) / (lo + 0.05);
    };
    

    对用于显示文本的每种颜色都进行此类检测,能够及时发现肉眼容易忽略的问题。以一种暖色调的纸张风格主题为例:屏幕上所有颜色看起来都很正常——琥珀色比例为2.06:1,用于语法高亮的关键词红色比例为2.80:1,用于显示数字的金色比例为2.50:1。这三种颜色都经过了肉眼检验,但均不满足AA级标准。

    WCAG 2.x的标准存在一个已知缺陷:它将浅色背景上的深色文字与深色背景上的浅色文字视为相同,因此即使使用深色调方案也能获得较高的评分,但视觉效果仍可能过于刺眼。正是由于这一缺陷,人们才开发出了APCA标准,不过APCA并未被纳入WCAG 2.2,目前也尚未在任何地方成为强制标准。

    关于深色模式与可读性的研究结论

    这一观点常常被误述。Nielsen Norman Group的研究总结指出:

    • 在Piepenbrock等人(2013年,《人机工程学》)和Dobres等人(2017年,《应用人机工程学》)的研究中,对于视力正常的人而言,浅色模式在各项测试指标上的表现都更佳。字体尺寸越小,这种优势就越明显。其原因在于光学原理:浅色背景上的深色文字会反射更多光线,使瞳孔收缩,而较小的瞳孔能减少球面像差并提升景深。
    • 对于视力较差的人,Legge等人(1985年,《视觉研究》)发现,7名患有眼介质混浊(尤其是白内障)的受试者在深色模式下阅读速度更快。
    • Aleman等人(2018年,《科学报告》)指出,长期处于浅色模式可能因脉络膜变薄而增加近视风险。
    • 实际建议是允许用户根据需求自行切换到深色模式。

    客观来看,深色模式主要是为了满足个人偏好和某些无障碍需求,并不能证明能提升普通用户的阅读体验。这正是支持采用三态控制而非将深色模式设为默认值的最强理由。

    关键要点

    • 将深色模式视为三个独立问题:主题选择、颜色切换,以及在首次渲染前应用所选设置。
    • 根据功能角色为相关标识符命名,在一个data-theme块中切换这些设置,并通过元标签确保根节点的color-scheme与之保持同步。
    • 在首次渲染前通过服务器读取的Cookie或阻塞式内联脚本来确定主题,同时接受该脚本带来的内容安全策略限制。
    • 提供浅色、深色和系统主题三种状态,仅当选择系统主题时才跟随操作系统的变化。
  • 如果没有人需要切换功能,使用 prefers-color-scheme 而非令牌层是最快最简单的解决方案。
  • 切勿在您控制的页面上使用 filter: invert():这会使得每个固定元素的父容器变为根元素。
  • 需要分别为深色主题重新检查对比度、图片、iframe以及强制颜色显示的行为;仅仅切换令牌并不能自动解决这些问题。