Skip to content

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

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 ​

docs/.vitepress/theme/components/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 ​

docs/.vitepress/theme/components/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 ​

docs/.vitepress/theme/components/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,切换无闪烁。

六、验证清单 ​

改完后,测试这三步:

  1. 冷启动:暗色下关闭标签页 → 重新打开 → 卡片直接是暗色,无闪烁
  2. 切换:亮 ↔ 暗来回切,颜色全部跟随
  3. 刷新:暗色下按 F5,颜色保持正确

三步全过,说明方案生效。

七、总结 ​

  1. 暗色适配优先走 CSS:VitePress 在 <html> 上挂了 .dark 类,CSS 直接读它
  2. :global(.dark .xxx) 是稳定解:无 JS 依赖,冷启动 / 切换 / 刷新全周期一致
  3. :global() 要包住整个选择器,避免 scoped 属性加错元素
  4. height 用 min-height:内容自适应更稳

常见问题(FAQ) ​

VitePress 暗色模式下首页出现白底怎么办? ​

两层原因叠加:

  1. VitePress 的 .VPHome 默认背景色是 --vp-c-bg(亮色下为白色)
  2. 自定义容器没覆盖背景色,或用了固定 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 内置能力。

最后更新2026/09/13 11:35
如果你觉得这篇文章有帮助,或者想聊聊技术、工作,欢迎通过下面方式联系我:
contact fishfinal