别让深色模式闪瞎用户:vibe 项目的暗色模式与主题切换实战
AI 生成的暗色模式有三种固定套路的翻车:颜色写死散落各文件、切换时全屏闪白、系统偏好与用户选择打架。本指南给完整工程解法:语义化 CSS 变量(design token)作为唯一真相源、Tailwind v4 的 @custom-variant 接线、system/light/dark 三态状态机与 localStorage vs cookie 持久化规则、根治 SSR 首屏闪烁的 <head> 内联脚本(含 Next.js App Router 实操与量化验收),以及深水区——图片、canvas 图表、iframe 嵌入、WCAG AA 对比度抽查法,最后附硬编码颜色迁移的 5 步 SOP(含 AI 批量改写 prompt 与回归检查清单)。

本篇讲的是主题切换的工程实现,与站内两篇设计类指南明确分工:vibe-project-i18n-guide(国际化)讲的是"同一套 UI 如何讲多种语言",vibe-project-rich-text-editor-guide(富文本编辑器)讲的是"内容如何被创作和排版"——本篇不讲文案、不讲排版,只讲颜色如何从写死变成可切换的系统,互链 https://vibefix.work/zh/explore/vibe-project-i18n-guide 、https://vibefix.work/zh/explore/vibe-project-rich-text-editor-guide 。
深夜十一点,你刚把 vibe 项目部署上线,关了灯打开手机想欣赏一下自己的作品——首屏"唰"地一下全白,刺得你眯起眼;好不容易缓过来点开右上角的月亮图标,页面闪了三次才切到深色,还有一半卡片 stubborn 地保持着白色背景,上面浮着浅灰色的字,根本看不清。这不是你一个人的遭遇:AI 写出来的项目,暗色模式十有八九是这个德行。不是 AI 不会写 dark:,而是它处理主题切换的方式,和处理其他需求一样——看起来跑通了,实际上全是坑。
1. 为什么 vibe 项目的暗色模式总翻车
先把三种最常见的翻车摆出来,对照一下你的项目中了几条。
翻车一:颜色被写死,改一个色要翻 40 个文件
/* AI 一把梭出来的典型代码:颜色散落在每个组件里 */
.card { background: #ffffff; color: #1a1a1a; }
.sidebar { background: #f5f5f5; }
.btn-primary { background: #4f46e5; }
/* 三个月后你想加暗色模式:全局搜 #fff,出来 237 个结果,祝好运 */
这是最底层的病:颜色没有经过任何抽象层,直接裸写在样式里。人手写的项目好歹会顺手抽几个变量,AI 生成的代码是按组件"并行"写的,每个组件自成一体,#ffffff 在 40 个文件里各表一遍。暗色模式不是"加个开关"的事,它要求你回答一个问题:页面上每一个色块,在深色下应该是什么色?没有抽象层,这个问题要回答 237 次;有抽象层,只需要回答十几次——在 token 定义处。
翻车二:切换主题时全屏闪白,用户以为出 bug 了
你点了月亮图标,页面先白一下再变黑;或者更惨的:深夜打开网站,首屏先闪一道白光再切深色。这叫 FOUC(Flash of Unstyled Content,无样式内容闪烁),在主题切换场景下有个更贴切的名字:闪瞎。根因很直白:浏览器 paint 的时候,JS 还没读到用户的主题偏好——偏好存在 localStorage 里,而读 localStorage 的 JS 是在 HTML 解析之后才执行的。浏览器按默认(浅色)先画了一帧,JS 后知后觉把主题改成深色,于是用户看到了完整的一帧白色。慢速网络下这个过程能持续半秒以上,足够让用户觉得"这网站坏了"。第 4 节给根治方案,先记住结论:主题决策必须发生在第一次 paint 之前,而不是 hydration 之后。
翻车三:系统偏好和用户选择打架,切完就回不来了
AI 最爱写的逻辑是这样的:页面加载时读 prefers-color-scheme,跟随系统;用户点了切换按钮,写 localStorage。听起来没问题,直到用户想"恢复跟随系统"——发现根本没有这个选项。或者反过来:用户明明手动选了深色,某天把系统切到浅色,网站莫名其妙跟着变了,因为代码里"跟随系统"的监听器还在跑,无视了用户的显式选择。这是状态机没画清楚:"没选过"和"选了浅色"是两种完全不同的状态,但 AI 的代码里它们长得一模一样——都是 localStorage 里没有值。正确模型是三态的,见第 3 节。
三者的共同根因一句话:AI 把"主题"当成一组样式问题,而它实际上是一个状态管理问题。颜色需要抽象层(第 2 节),主题需要状态机(第 3 节),首屏需要时序保证(第 4 节)。下面逐个拆开。
2. 架构选型:CSS 变量(design token)是唯一正解
先给结论,再解释为什么其他路都走不通:所有颜色必须经过 CSS 变量这一层,变量按语义命名,主题切换 = 整批替换变量的值。
/* ✅ 正解:语义化 token。组件只引用语义,主题只定义语义的值 */
:root {
--bg-primary: #ffffff; /* 页面底色 */
--bg-secondary: #f5f5f5; /* 卡片底色 */
--text-primary: #1a1a1a; /* 正文 */
--text-secondary: #6b7280; /* 辅助文字 */
--border-default: #e5e7eb;
--brand: #4f46e5; /* 品牌色:深浅模式下通常不变 */
}
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--border-default: #334155;
--brand: #4f46e5;
}
.card { background: var(--bg-secondary); color: var(--text-primary); }
命名:--bg-primary,而不是 --gray-900
这是 token 设计里最容易被 AI 写错的地方。AI 喜欢按色值命名:--gray-900: #111827,然后深色模式下 --gray-900 里装的却是浅色——名字和值彻底脱钩,半年后没人敢动。正确姿势是按"角色"命名,不按"长相"命名:--bg-primary 说的是"这是页面主背景",至于它在浅色下是白、深色下是深蓝,那是主题定义的事。命名分层可以再细一点:底层 --slate-900 这类原始色板(primitive)只管"有哪些颜色",上层语义 token(semantic)只管"哪里用哪个"。AI 生成代码时,你要盯的就是这一条:组件里出现裸色值或 primitive 色板直接引用,一律打回。
Tailwind 的取舍:dark: variant 可以用,但要配 @custom-variant
Tailwind 用户有两条路。第一条是继续用 dark: variant:class="bg-white dark:bg-slate-900"。这在组件库里写起来顺手,但代价是每个用颜色的地方都要写两遍,漏一处就是翻车一里的"一半卡片保持白色"。第二条是把 Tailwind 的颜色全部接到 CSS 变量上:bg-(--bg-primary)(v4 语法)或在 theme 里映射变量,然后主题切换只动 [data-theme]。我的建议是:新项目直接走 CSS 变量 + data-theme,Tailwind 只负责布局间距;老项目如果已经 dark: 写得到处都是,至少把 variant 的触发器从默认的 prefers-color-scheme 改成你的 data 属性——Tailwind v4 官方文档给了标准写法:
/* app.css(Tailwind v4):让 dark: 跟随你的 data-theme,而不是系统 */
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
这一行的作用是:dark:bg-slate-900 不再监听系统媒体查询,而是看祖先有没有 data-theme="dark"。这样"用户显式选择"才能真正覆盖系统偏好——否则你 JS 里切了 data-theme,Tailwind 的 dark: 还在听系统的,两边各说各话,就是翻车三的现场版。Tailwind 官方文档在 dark-mode 一节明确写了三态切换(light / dark / system)的推荐做法:自定义 variant + window.matchMedia 检测系统偏好——和本篇第 3 节的状态机是同一套模型,互相印证。
两条邪路:两套 class 硬切,filter: invert(1)
邪路一:"两套 class 硬切"——body.light .card{...} body.dark .card{...} 把每个组件的样式写两遍。这本质上是 CSS 变量的手工低配版:变量是"定义一次、引用处自动跟随",硬切是"每个引用处手写两遍"。组件一多必然漏,漏了就是阴阳脸。更糟的是第三方组件:你不可能给 node_modules 里的每个 class 都补一套 dark 写法。
邪路二:"filter: invert(1) 一键深色"——给 html 加个反色滤镜,浅色秒变深色。演示时效果拔群,上线后处处是坑:图片也被反色(人物照片变成底片效果)、品牌色面目全非(你的品牌蓝变成屎黄色)、box-shadow 和渐变全乱、性能上整页走 GPU 滤镜重绘。唯一合理的用途是给用户提供"强制深色"的无障碍兜底,而不是你的主题实现方案。记住判断标准:凡是"不用回答每个色块在深色下是什么色"的方案,都是在逃避第 2 节开头那个问题,债迟早要还。
3. 三态模型:system / light / dark
主题状态机只有三种状态,但 AI 的代码通常只实现了两种(浅 / 深),把"跟随系统"当成"初始默认值"一笔带过——这就是翻车三的根子。下面这张图是本篇的核心模型,建议直接照着实现:
/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/e789172f-aca6-4f21-b214-8587bb953ec1.png
三个要点:
- 用户显式选择永远覆盖系统偏好。一旦用户点过切换按钮,
matchMedia('(prefers-color-scheme: dark)')的监听器就只剩一个作用:什么都不做。很多 AI 生成的代码在这里犯错——监听器无条件执行,用户选了深色、系统切浅色,页面跟着跳,用户会觉得"我的设置没保存"。 - "没选过"是一个独立状态。存储里用三值:
'light' | 'dark' | null(null = 跟随系统),而不是布尔值。用布尔值isDark的代码,根本表达不出"跟随系统",最后只能再加一个followSystem布尔,两个布尔组合出四种状态,其中一种还是非法的——状态爆炸就是这么来的。 - 清除显式选择要回到跟随系统。切换器 UI 给三个选项(浅色 / 深色 / 跟随系统),而不是一个开关。苹果、GitHub、Vercel 的设置页都是三选项,不是没道理的:开关只有两种状态,表达不了三态模型。
持久化放哪:localStorage vs cookie
这是个架构级决策,选错了第 4 节的根治方案直接作废:
- localStorage:读写都在客户端,服务端渲染(SSR)时完全不知道用户是什么主题。首屏只能先按默认渲染,再等 JS 纠正——这就是闪白的来源。纯客户端渲染(SPA)项目用它没问题,反正首屏本来就要等 JS。
- cookie:随每个请求发到服务端,SSR 可以在渲染 HTML 时就把正确的
data-theme写进<html>标签,首屏一次到位、零闪烁。代价是每次请求多带几十个字节,以及切换主题时要调一次接口写 cookie(或用 JS 写 cookie + 刷新)。
决策很简单:有 SSR(Next.js / Nuxt / SvelteKit)就用 cookie,没有就用 localStorage。混合方案也行:cookie 存一份给 SSR 看,localStorage 存一份给客户端 JS 看,切换时两边一起写。但别只存 localStorage 然后抱怨 SSR 闪白——那是你自己的选择。
// 三态 hook(React):null = 跟随系统
import { useEffect, useState } from 'react';
export type Theme = 'light' | 'dark' | null;
export function useTheme() {
const [theme, setTheme] = useState<Theme>(null);
useEffect(() => {
const saved = document.cookie.match(/(?:^|; )theme=(light|dark)/)?.[1]
?? localStorage.getItem('theme');
setTheme(saved === 'light' || saved === 'dark' ? saved : null);
}, []);
const apply = (t: Theme) => {
setTheme(t);
const resolved = t ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = resolved;
// cookie 给 SSR 看(30 天),localStorage 给客户端看
document.cookie = `theme=${t ?? ''}; path=/; max-age=${30 * 86400}`;
if (t) localStorage.setItem('theme', t); else localStorage.removeItem('theme');
};
return { theme, apply };
}
4. SSR 水合闪烁根治:inline script 是唯一的解
第 2 节解决了"颜色怎么组织",第 3 节解决了"状态怎么存",这一节解决"首屏怎么不闪"。先把丑话说在前面:凡是"等 React hydration 之后再读主题"的方案,闪白都是注定的——hydration 本来就发生在首次 paint 之后,你拿什么去纠正已经画出来的一帧?唯一的解是让主题决策发生在 HTML 解析阶段、第一次 paint 之前。做法是在 <head> 里放一段内联脚本(inline script),阻塞式执行:读偏好 → 写 data-theme → 浏览器再 paint。这段脚本必须内联(不能是外链 JS,外链要等下载),必须尽量短(阻塞解析,几十行以内),必须放在所有样式之前。
<!-- layout.tsx / index.html 的 <head> 最顶部:阻塞执行,paint 之前定主题 -->
<script>
(function () {
try {
// 1. 显式选择优先(cookie 给 SSR,localStorage 兜底纯 CSR)
var m = document.cookie.match(/(?:^|; )theme=(light|dark)/);
var t = (m && m[1]) || localStorage.getItem('theme');
// 2. 没有显式选择才看系统
if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
// 3. 在浏览器第一次 paint 之前写死,后续 CSS 直接命中正确变量
document.documentElement.dataset.theme = t;
} catch (e) { /* 隐私模式禁用存储时:保持默认浅色,不抛错 */ }
})();
</script>
Next.js App Router 的实操有三个细节:第一,这段脚本要进 app/layout.tsx 的 <head>,用 dangerouslySetInnerHTML 内联(它是字符串,不走打包,外链反而更慢);第二,如果用了 cookie 方案,服务端组件可以直接读 cookie 把 data-theme 写在 <html> 标签上,内联脚本退化成"客户端纠偏"(处理用户在别的标签页改了主题的极端情况);第三,<html> 标签上加 suppressHydrationWarning——服务端渲染的 data-theme 和客户端算出来的在边界情况下可能不一致(比如服务端没 cookie、按默认渲染),不加这个 React 会报 hydration mismatch 的警告。注意这是警告级别的问题:属性不一致不会导致整树重渲染,别为了消警告把主题逻辑又搬回 hydration 之后,那就本末倒置了。
有人会问:直接用 next-themes 不就行了?可以,它内部做的就是同一件事(ThemeProvider + 内联脚本防闪)。但你要知道它替你做了什么,否则出问题时你只能干瞪眼:它的 attribute="class" 模式往 <html> 写 class,defaultTheme="system" 对应本篇的三态模型,enableSystem 对应 matchMedia 监听。一人团队直接用它没问题,前提是它的默认行为(localStorage 持久化)符合你的架构决策——SSR 项目记得配成读 cookie 或接受它"首屏按系统渲染"的折中。
下图是两种方案的时序对比,左边是你现在的样子,右边是修完的样子:
/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/04d20569-62ae-41c1-b109-90639a0a32a4.png
验收标准要量化,别靠"感觉不闪了":Chrome DevTools → Performance 录一次刷新(深色偏好 + 慢速 3G 节流),时间线里 paint 事件应该只出现一次深色帧;Network 面板 Disable cache 后硬刷新三次,肉眼 0 闪白。把这条写进你的发布检查表——主题闪烁是那种"你觉得修好了、用户换个网络又复现"的 bug,不量化验收等于没修。
5. 深水区:图片、图表、第三方嵌入
颜色系统搭完、首屏不闪了,真正的深水区才刚开始:页面上不是所有东西都听 CSS 变量的话。
图片:picture + source,但有个陷阱
<!-- 同一张 logo 的浅色版 / 深色版 -->
<picture>
<source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
<img src="/logo-light.svg" alt="logo" />
</picture>
陷阱在于:media="(prefers-color-scheme: dark)" 只跟系统偏好,不跟用户显式选择。用户在浅色系统上手选了深色主题,图片还是浅色版——阴阳脸。根治办法是 JS 版:监听你的主题状态,给 img 换 src(或给 picture 动态改 media)。更省事的架构决策是:logo 和图标尽量用 SVG currentColor,一套文件两种主题通吃,只有照片、截图这种没法矢量化的才走双版本。能少维护一套图片,就少一个翻车点。
图表:canvas 不吃 CSS,重绘要自己来
ECharts、Chart.js 这类 canvas 图表,颜色是画进像素里的,CSS 变量变了它不知道。标准做法:把图表配色也接到你的 token 上——从 getComputedStyle(document.documentElement).getPropertyValue('--text-secondary') 读变量,主题切换时 chart.setOption / chart.update() 重绘。别在 JS 里硬编码两套色板,那样你第 2 节的 token 体系就白建了:图表颜色应该是"读 token",而不是"另一套 token"。
第三方嵌入:iframe 是主权豁免区
嵌进来的 Disqus 评论、YouTube 播放器、Twitter 卡片,跑在跨域 iframe 里,你的 CSS 根本伸不进去。能做的有三层:第一,看嵌入方是否支持主题参数(YouTube 的 theme=dark、Giscus 的 data-theme),支持就随你的主题状态同步传参;第二,给 iframe 容器加 color-scheme: dark——这是 CSS 的标准属性,告诉浏览器"这个区域按深色渲染原生控件",滚动条、表单控件、选区颜色会跟着走,Chrome/Firefox/Safari 都支持;第三,不支持的嵌入,用深色容器 + 圆角把它"框"起来,视觉上隔离,承认这是主权豁免区。记住 color-scheme 要写在 :root 和 [data-theme="dark"] 上各一份,浅色下别残留 color-scheme: dark,否则浅色页面的滚动条是黑的,更怪。
对比度:深色模式不是把字调灰就行
AI 生成深色模式时最爱干的事:正文 #ffffff 太刺眼?改成 #9ca3af 压一压。压着压着就压到了 WCAG 的红线以下。WCAG 2.1 的 1.4.3 条款(Contrast Minimum)是硬标准:正文与背景对比度至少 4.5:1,大文本(18pt / 14pt 加粗以上)至少 3:1,AA 级。这是 W3C 写死在理解文档里的数字,不是"建议"。深色模式下最容易翻车的是辅助文字和占位符:#6b7280 在 #111827 上的对比度大约只有 4.6:1,勉强过线;再浅一档就跌破。抽查法:每种主题下,抓正文、辅助文字、按钮文字、占位符、禁用态五类,逐个过一遍对比度计算器(Chrome DevTools 的颜色选择器直接显示对比度比值,点两下就行)。别全量自动化——全量跑出来的 200 个"警告"你根本不会看,五类抽查能抓住 95% 的真问题。还有一条冷知识:对比度只看明度不看色相,红配绿只要明度差够大也能过 AA——所以"深色模式多用彩色点缀"是安全的,别被直觉骗了。
6. 迁移 SOP:已有项目从硬编码迁到 token 的 5 步
如果你现在的项目就是第 1 节翻车一的现场(237 个 #fff),别想着"抽空重构",按下面 5 步走,一个下午能迁完一个中等项目:
第 1 步:扫描建账。全局搜 hex / rgb / hsl 色值,按"出现次数"排序。出现 10 次以上的色,99% 是该进 token 的;只出现 1~2 次的,先记下来别动——可能是某张特定插图的配色,硬塞进 token 反而污染语义。
第 2 步:建 token 表。对照第 2 节的语义命名,把高频色映射到 --bg-* / --text-* / --border-* / --brand 等语义 token,深色值 contemporaneously 定掉(别留"深色以后再说",以后就是永远)。品牌色、成功/警告/错误这类状态色通常深浅通用,直接复用。
第 3 步:AI 批量改写。把下面的 prompt 喂给 AI(按你的项目情况改括号部分),让它逐文件替换。关键约束有三条,缺一不可:
把 (src/components) 下所有硬编码色值替换为 CSS 变量引用,规则:
1. 只替换"界面色"(背景/文字/边框/阴影色),SVG 插图内部配色、图片 URL、
第三方组件覆盖样式一律不动,动了就是事故;
2. 映射表:#ffffff→var(--bg-primary),#f5f5f5→var(--bg-secondary),
#1a1a1a→var(--text-primary),#6b7280→var(--text-secondary),
#e5e7eb→var(--border-default)……(贴上第 2 步的完整映射表);
3. 每次只改 5 个文件,改完 git diff 给我看,确认无误再继续。
遇到映射表里没有的色值,停下来问我,不要自己编一个变量名。
第 4 步:回归检查。按下面的清单逐项打勾,✓ 才能合:
- ✓ 全局搜
#开头的 hex:组件样式里残留 0 个(SVG 插图内部除外) - ✓ 浅色 / 深色 / 跟随系统三种状态逐一切换,肉眼扫一遍核心页面,无阴阳脸
- ✓ 深色偏好 + 慢速 3G 硬刷新三次,0 闪白(第 4 节验收标准)
- ✓ 五类文本对比度抽查(第 5 节),深浅两套主题各跑一遍
- ✓ 图表页切换主题,canvas 重绘且颜色正确
- ✓ 第三方嵌入(评论/视频)容器无违和,或已传主题参数
第 5 步:删死色,加门禁。确认无残留后,删掉不再用的旧变量;CI 里加一条简单规则:组件样式文件新增 hex 色值就告警(stylelint 的 color-no-hex 或自定义 grep 钩子都行)。迁移是一次性的,门禁是防止 AI 下次一把梭又把颜色写回去——vibe 项目的维护,一半是防 AI 故态复萌。
7. 一句话总结
暗色模式不是"多写一套样式",而是三件事:颜色走语义化 CSS 变量(第 2 节)、主题走 system/light/dark 三态状态机(第 3 节)、首屏决策发生在第一次 paint 之前(第 4 节)。三件事各解决一类翻车,少了任何一件,用户迟早在深夜被你的网站闪一下。相关阅读:多语言不只是翻译:vibe 项目的国际化实战(同一套 UI 讲多种语言)、富文本编辑器选型与落地(内容如何被创作和排版)——文案与排版归它们,颜色与主题归本篇。
评论 (0)
相关文章

AI 半小时就能交出一个 Cmd+K 面板,但匹配排序、快捷键冲突、输入法闪烁、移动端降级才是它翻车的地方。本文给一人团队一条完整路线:命令注册表数据模型、五步模糊匹配评分管线、带冲突检测的全局快捷键体系、无障碍键盘闭环、移动端入口降级、与权限联动的命令,以及上线前 10 项检查表。

每个 vibe 项目都有一堆表单,AI 生成的只会堆 required——模糊的文案、异步竞态、重复提交、丢失的草稿才是用户流失的地方。本文给一人团队完整的表单实战:校验三层分工、错误文案改写、防抖防竞态的异步校验、idle/submitting/success/error 提交状态机与防重提交三保险、文件型表单、多步表单草稿、无障碍,以及上线前 12 项检查表。

AI 200 行就能跑起一个富文本编辑器 demo,但粘贴清洗、移动端键盘、Markdown 往返才是真正的坑。本文给一人团队一条实战路线:Tiptap/BlockNote 选型四维打分、JSON 存储与 Markdown 导出组合、Next.js SSR 水合解法、白名单粘贴清洗管线、图片直传链路,以及上线前 10 项检查表。