Appearance
VitePress 暗色主题适配实战:从白底漏光到全站统一

VitePress 自定义首页在暗色模式下白底漏光、组件不变色,是常见的主题适配问题。
解决方案一句话:用 CSS 变量定义亮色配色,用 :global(.dark .xxx) 选择器在暗色下覆盖变量。
css
/* 亮色 */
.stats-section {
--hs-bg: #ffffff;
background: var(--hs-bg);
}
/* 暗色 */
:global(.dark .stats-section) {
--hs-bg: #1e293b;
}不需要 JS、不需要 isDark、不需要 provide/inject。
下面完整讲实现,以及 3 个容易踩的坑。
一、问题现象
VitePress 自定义首页包含三个组件:
HomeHero:顶部 Hero 区HomeStats:悬浮在 Hero 上的统计卡片InfoContainer:两组特性卡片
切换到暗色模式后:
- Hero 正常(本来就是蓝色背景)
- Hero 下方出现一大片白色区域
HomeStats/InfoContainer卡片仍是白底黑字
用 DevTools 定位,白底来自 VitePress 的 .VPHome(默认 --vp-c-bg 是白色)。而你的组件没有覆盖背景色,就露出了父级白底。
二、方案:CSS 变量 + .dark 选择器
2.1 整体思路
VitePress 在切换主题时,会在 <html> 上挂/删 .dark 类。CSS 直接读它,无需任何 JS:
VitePress 内联脚本
│
▼
<html class="dark"> ← 渲染前就已挂载
│
▼
CSS :global(.dark .xxx) ← 直接读类,无需 JS
│
▼
CSS 变量覆盖 → 颜色切换2.2 为什么不用 isDark
useData().isDark 有 SSR 时序陷阱:SSR 阶段固定为 false,客户端水合后才会读 localStorage 修正。用 provide/inject 传递时,子组件首次渲染拿到的就是 false 快照,冷启动会出现"DOM 有 dark 类、组件还是亮色"的错位。
纯样式切换用 .dark 就好。只有需要 JS 逻辑判断(比如根据主题换图片)才用 isDark。
三、三个组件的完整实现
3.1 Container.vue
vue
<script lang="ts" setup>
import { computed } from 'vue';
const props = withDefaults(
defineProps<{ height?: number | string }>(),
{ height: 800 }
);
const heightStyle = computed(() => ({
minHeight: typeof props.height === 'number' ? `${props.height}px` : props.height,
}));
</script>
<template>
<div class="theme-container" :style="heightStyle">
<slot />
</div>
</template>
<style scoped>
/* 亮色 */
.theme-container {
width: 100%;
background: #eef4fa;
transition: background-color 0.3s ease;
}
/* 暗色 */
:global(.dark .theme-container) {
background: #0f172a;
}
</style>注意:height 改用 minHeight,避免内容不足时下方露出父级白底。
3.2 HomeStats.vue
vue
<script lang="ts" setup>
const stats = [
{ title: '50+', desc: '深度技术文章与实战教程' },
{ title: '10年', desc: '一线开发与架构经验沉淀' },
{ title: '99.9%', desc: '读者反馈的实用性与好评率' },
{ title: '3万+', desc: '月度访问与订阅读者' },
];
</script>
<template>
<section
class="stats-section flex justify-center gap-8 rounded-2xl max-w-6xl mx-auto -mt-32 py-16 px-5 relative z-10 flex-wrap"
>
<div v-for="(stat, i) in stats" :key="i" class="flex-1 min-w-[160px] text-center mb-4 sm:mb-0">
<div class="stats-title text-2xl font-bold mb-1">{{ stat.title }}</div>
<div class="stats-desc text-base opacity-90">{{ stat.desc }}</div>
</div>
</section>
</template>
<style scoped>
/* 亮色 */
.stats-section {
--hs-bg: #ffffff;
--hs-title: #111827;
--hs-desc: #333333;
--hs-shadow: 0 6px 40px rgba(30, 60, 170, 0.07);
background: var(--hs-bg);
box-shadow: var(--hs-shadow);
transition: background-color 0.3s, box-shadow 0.3s;
}
/* 暗色 */
:global(.dark .stats-section) {
--hs-bg: #1e293b;
--hs-title: #f1f5f9;
--hs-desc: #cbd5e1;
--hs-shadow: 0 6px 40px rgba(0, 0, 0, 0.5);
}
.stats-title { color: var(--hs-title); }
.stats-desc { color: var(--hs-desc); }
</style>技巧:把 --hs-* 变量定义在根元素上,.dark 下整体覆盖,子元素只引用变量,不用写任何条件样式。
3.3 InfoContainer.vue
vue
<script lang="ts" setup>
import { Feature } from '../types/feature';
import { computed } from 'vue';
import { VPLink, VPImage } from 'vitepress/theme';
const props = withDefaults(
defineProps<{ title: string; subtitle?: string; cols?: number; data: Feature[] }>(),
{ cols: 3 },
);
const gridClass = computed(() => {
return ['grid', `grid-cols-${props.cols}`, `md:grid-cols-${props.cols}`, 'gap-6'];
});
</script>
<template>
<section class="info-section py-16">
<div class="max-w-6xl mx-auto text-center">
<h2 class="info-title text-3xl md:text-4xl font-bold mb-4">{{ title }}</h2>
<div v-if="subtitle" class="info-subtitle mb-12">{{ subtitle }}</div>
<slot name="extra"></slot>
<div :class="gridClass">
<template v-for="(item, idx) in data" :key="idx">
<div
class="info-card w-full rounded-xl p-6 text-left min-w-[290px]"
:class="{ 'is-link': item.link }"
>
<template v-if="item.link">
<VPLink :href="item.link">
<div class="info-card-title font-bold text-2xl mb-6 flex flex-row">
<div class="flex-initial rounded-full h-6 w-6 mr-4" v-if="item.icon">
<VPImage :image="item.icon" />
</div>
<div class="flex-initial">{{ item.title }}</div>
</div>
<div class="info-card-body text-base leading-relaxed h-52" v-html="item.details"></div>
</VPLink>
</template>
<template v-else>
<div class="info-card-title font-bold text-2xl mb-6">{{ item.title }}</div>
<div class="info-card-body text-base leading-relaxed" v-html="item.details"></div>
</template>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
/* 亮色 */
.info-section {
--ic-bg: #eef4fa;
--ic-title-from: #2563eb;
--ic-title-to: #10b981;
--ic-subtitle: #6b7280;
--ic-card-bg: #ffffff;
--ic-card-border: #ffffff;
--ic-card-shadow: 0 6px 32px rgba(30, 60, 170, 0.08);
--ic-card-title: #111827;
--ic-card-body: #374151;
background: var(--ic-bg);
transition: background-color 0.3s ease;
}
/* 暗色 */
:global(.dark .info-section) {
--ic-bg: #0f172a;
--ic-title-from: #60a5fa;
--ic-title-to: #34d399;
--ic-subtitle: #94a3b8;
--ic-card-bg: #1e293b;
--ic-card-border: #334155;
--ic-card-shadow: 0 6px 32px rgba(0, 0, 0, 0.4);
--ic-card-title: #f1f5f9;
--ic-card-body: #cbd5e1;
}
.info-title {
background: linear-gradient(to right, var(--ic-title-from), var(--ic-title-to));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.info-subtitle { color: var(--ic-subtitle); }
.info-card {
background: var(--ic-card-bg);
border: 1px solid var(--ic-card-border);
box-shadow: var(--ic-card-shadow);
transition: background-color 0.3s, border-color 0.3s, box-shadow 0.3s;
}
.info-card-title { color: var(--ic-card-title); }
.info-card-body { color: var(--ic-card-body); }
.info-card.is-link:hover {
border-color: #4f46e5;
cursor: pointer;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
}
</style>注意:bg-gradient-to-r from-blue-600 to-emerald-500 这类 Tailwind 类不能跟随 .dark 动态切换,渐变文字必须移到 CSS。
四、3 个关键坑位
坑 1::global() 要包住整个选择器
错误写法:
css
:global(.dark) :deep(.theme-container) {
background: #0f172a;
}编译后:
css
.dark[data-v-4381eb5f] .theme-container { background: #0f172a; }data-v-xxx 加到了 .dark 上,而 <html class="dark"> 没有这个属性 —— 选择器永远命中不了,暗色完全失效。
正确写法:
css
:global(.dark .theme-container) {
background: #0f172a;
}编译后是纯净的 .dark .theme-container,能正常命中。
规则:只要选择器要命中的是 scoped 组件外部的祖先元素(如 <html>),整个选择器链都必须放进 :global()。
坑 2:height 固定值会露白
Container :height="1200" 用的是 height,内容不满时下方留白,露出父级白底。改成 min-height 就解决了。
坑 3:Tailwind 动态类名会被 purge
ts
`grid-cols-${props.cols}` // ❌ 可能被 Tailwind 树摇掉Tailwind 编译时扫描源码里的完整类名,动态拼接的会被清理。解决方式:
- 用映射表写死
- 或
tailwind.config里加safelist
五、最终效果
| 区域 | 亮色 | 暗色 |
|---|---|---|
| 页面底 | #eef4fa | #0f172a |
| 卡片底 | #ffffff | #1e293b |
| 卡片边框 | #ffffff | #334155 |
| 标题渐变 | #2563eb → #10b981 | #60a5fa → #34d399 |
| 正文 | #374151 | #cbd5e1 |
全站统一过渡 0.3s,切换无闪烁。


六、验证清单
改完后,测试这三步:
- 冷启动:暗色下关闭标签页 → 重新打开 → 卡片直接是暗色,无闪烁
- 切换:亮 ↔ 暗来回切,颜色全部跟随
- 刷新:暗色下按 F5,颜色保持正确
三步全过,说明方案生效。
七、总结
- 暗色适配优先走 CSS:VitePress 在
<html>上挂了.dark类,CSS 直接读它 :global(.dark .xxx)是稳定解:无 JS 依赖,冷启动 / 切换 / 刷新全周期一致:global()要包住整个选择器,避免 scoped 属性加错元素height用min-height:内容自适应更稳
常见问题(FAQ)
VitePress 暗色模式下首页出现白底怎么办?
两层原因叠加:
- VitePress 的
.VPHome默认背景色是--vp-c-bg(亮色下为白色) - 自定义容器没覆盖背景色,或用了固定
height
解决:给容器加背景色,用 :global(.dark .xxx) 跟随主题,同时把 height 改为 min-height。
VitePress 的 .dark 类和 isDark 应该用哪个?
.dark:VitePress 挂在<html>上的 CSS 类,页面渲染前就挂载 —— 纯样式切换用它isDark:useData()暴露的 JS 状态,水合完成才同步 —— 需要 JS 逻辑时用它
为什么 :global(.dark) :deep(.xxx) 不生效?
Vue scoped 编译器把 data-v-xxx 加到了 .dark 上,而 <html> 没有这个属性,选择器永远命中不了。
正确写法是 :global(.dark .xxx)。
需要引入额外依赖吗?
不需要。CSS 变量、:global() 都是 VitePress 与 Vue 内置能力。
