Skip to content

VitePress 博客首页美化:不换主题,用 CSS 和 Vue 组件打造个性化布局

VitePress 的默认主题是为文档站设计的——干净、专业、信息密度高。但当你把它用作个人博客时,首页的「文档感」就显得有点冷清了。

本文记录如何在不更换主题、不引入第三方美化插件的前提下,通过 CSS 变量、Vue 组件和少量动画,把 VitePress 默认主题改造成一个有卡片布局、个人档案栏和视觉层次的博客首页。整个过程只涉及一个 Vue 组件文件和一个 CSS 文件。

目标与约束

设计上参考了 Vivia 的布局比例:左侧主内容区 + 右侧窄栏,卡片式文章列表,顶部带有醒目的 banner。色系和字体沿用 VitePress 默认主题的 CSS 变量,确保暗色模式自动适配。

约束条件:

  • 零第三方依赖:不安装任何美化插件或 UI 库。VitePress 生态中目前没有成熟的博客美化插件(VitePress Blog 等仍处于早期阶段)。
  • 不 fork 主题:所有定制通过 custom.css 覆盖和 Vue 组件实现,升级 VitePress 时不会产生合并冲突。
  • 首页与文章页视觉统一:两个页面共享布局参数(最大宽度、间距、侧栏宽度),用户在页面间切换时不应感到视觉跳动。

布局系统:CSS 变量统一首页与文章页

VitePress 文章页(.VPDoc)有自己的宽度控制逻辑,首页是完全自定义的 Vue 组件。如果各定各的宽度,用户在首页点进文章时会感到内容区突然变宽或变窄。

解决方案是在 :root 中定义一组共享的布局 token:

css
:root {
  --vp-layout-max-width: 1560px;
  --blog-page-max-width: 1260px;
  --blog-home-max-width: 1180px;
  --blog-content-max-width: 940px;
  --blog-aside-width: 250px;
  --blog-column-gap: 40px;
}
  • --blog-home-max-width 控制首页整体宽度。
  • --blog-content-max-width 控制文章页正文区宽度。
  • --blog-aside-width 同时用于首页侧栏和文章页右侧 Outline。
  • --blog-column-gap 统一主内容和侧栏之间的间距。

首页 .blog-home 直接引用这些变量:

css
.blog-home {
  max-width: var(--blog-home-max-width, 1180px);
  margin: 0 auto;
  padding: 34px 20px;
}

.home-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--blog-aside-width, 250px);
  gap: var(--blog-column-gap, 40px);
  align-items: start;
}

文章页通过 custom.css 覆盖默认主题的宽度:

css
.VPDoc.has-aside .content-container {
  max-width: var(--blog-content-max-width) !important;
}

.VPDoc.has-aside .content {
  max-width: var(--blog-content-max-width) !important;
}

.VPDoc.has-aside .aside {
  width: var(--blog-aside-width);
  padding-left: 24px;
}

这里用 !important 是因为 VitePress 默认主题的 CSS 优先级较高,正常覆盖不生效。这是在不 fork 主题的约束下不得已的选择。

一个调试过程中的教训:最初我把首页宽度设得和文章页一样(1260px),结果首页的侧栏看起来太挤——文章列表卡片占了太多空间。最终选择首页比文章页窄 80px(1180px vs 1260px),视觉上反而更均衡,因为首页没有 Outline 只有窄的 widget 栏。

首页 Banner

首页顶部的 Banner 用渐变背景和品牌色营造视觉焦点:

css
.home-banner {
  border-radius: 26px;
  padding: 28px 30px 24px;
  background:
    radial-gradient(circle at 12% 22%,
      color-mix(in srgb, var(--vp-c-brand-1) 18%, transparent),
      transparent 42%),
    radial-gradient(circle at 88% 12%,
      color-mix(in srgb, var(--vp-c-brand-3) 15%, transparent),
      transparent 38%),
    var(--vp-c-bg-soft);
  border: 1px solid color-mix(in srgb, var(--vp-c-brand-1) 20%, var(--vp-c-divider));
  box-shadow: 0 14px 32px rgba(0, 0, 0, 0.06);
}

这里大量使用了 color-mix() 函数——它允许将品牌色和透明色按比例混合,生成半透明的渐变层。好处是换品牌色时渐变自动跟着变,不需要手动调颜色。

var(--vp-c-brand-1) 是 VitePress 默认主题的主色,暗色模式下会自动切换,所以 Banner 在两种模式下都能正常显示。

文章卡片

每篇文章渲染为一个带圆角、边框和微妙阴影的卡片:

css
.post-card {
  background: var(--vp-c-bg-soft);
  border: 1px solid var(--vp-c-divider);
  border-radius: 22px;
  padding: 25px 28px;
  margin-bottom: 20px;
  transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}

.post-card:hover {
  transform: translateY(-3px);
  border-color: color-mix(in srgb, var(--vp-c-brand-1) 30%, var(--vp-c-divider));
  box-shadow: 0 18px 36px rgba(0, 0, 0, 0.1);
}

Hover 效果三管齐下:轻微上移(translateY(-3px))、边框变为品牌色、阴影加深。三者都使用 transition 而非 animation,确保只在交互时触发,不产生持续的性能开销。

文章标题使用衬线字体,和正文的无衬线字体形成对比:

css
.post-title {
  font-family: "Palatino Linotype", "Noto Serif SC", "Source Han Serif SC", serif;
  font-size: 1.62rem;
  letter-spacing: 0.01em;
}

Palatino Linotype 覆盖 Windows,Noto Serif SC / Source Han Serif SC 覆盖中文,fallback 到系统 serif。

个人档案卡片

右侧栏顶部是一个个人档案卡片,包含头像、姓名、一句话简介和文章/分类计数。

头像使用了两个动画效果:

光晕脉冲(Glow Pulse):头像外围一个模糊的光圈,持续呼吸式放大缩小。

css
.avatar-glow {
  position: absolute;
  inset: -8px;
  border-radius: 50%;
  background: radial-gradient(circle,
    color-mix(in srgb, var(--vp-c-brand-1) 35%, transparent),
    transparent 68%);
  filter: blur(10px);
  animation: glowPulse 2.8s ease-in-out infinite;
}

@keyframes glowPulse {
  0%, 100% { opacity: 0.5; transform: scale(0.95); }
  50%      { opacity: 0.9; transform: scale(1.08); }
}

悬浮感(Float):整个头像区域缓慢上下浮动。

css
.avatar-wrap {
  animation: floatUpDown 3s ease-in-out infinite;
}

@keyframes floatUpDown {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-4px); }
}

两个动画的周期略有错开(2.8s vs 3s),避免同步导致的机械感。动画幅度都很小(scale 0.95-1.08、translateY -4px),确保不喧宾夺主。

档案卡的背景从顶部的品牌色渐变到底部的默认背景色:

css
.profile-card {
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--vp-c-brand-1) 12%, transparent) 0%,
    var(--vp-c-bg-soft) 38%);
}

Widget 栏

侧栏的分类、归档、最近文章三个 Widget 共享统一的卡片样式:

css
.profile-card,
.widget {
  background: var(--vp-c-bg-soft);
  border: 1px solid var(--vp-c-divider);
  border-radius: 20px;
  padding: 18px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
}

分类和归档的数量显示为一个药丸形的 badge:

css
.badge {
  min-width: 1.5em;
  text-align: center;
  border-radius: 999px;
  background: color-mix(in srgb, var(--vp-c-brand-1) 16%, var(--vp-c-bg));
  color: var(--vp-c-brand-1);
  padding: 0 0.45em;
  font-size: 0.78rem;
}

「最近文章」Widget 有一个文本溢出问题:部分文章标题很长,在 250px 宽的侧栏里会溢出。解决方案是限制为两行,超出部分截断:

css
.recent-list a {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
  word-break: break-word;
}

同时在 HTML 标签上加 title 属性,鼠标悬停时显示完整标题:

html
<a :href="withBase(post.url)" :title="post.title">{{ post.title }}</a>

Sticky 侧栏

侧栏使用 position: sticky 固定在视口中,滚动时始终可见:

css
.side-column {
  position: sticky;
  top: calc(var(--vp-nav-height) + 20px);
  display: flex;
  flex-direction: column;
  gap: 16px;
}

top 值使用 VitePress 的 --vp-nav-height 变量加 20px 间距,确保侧栏不会被顶部导航栏遮挡。

文章页排版优化

除了首页,文章页的排版也做了调整。在 custom.css 中覆盖默认的字体大小、行高和间距:

css
.VPDoc .vp-doc {
  font-size: 16.5px;
}

.VPDoc .vp-doc p {
  line-height: 1.9;
  margin: 1em 0;
}

.VPDoc .vp-doc h1 {
  margin-top: 0.2em;
  margin-bottom: 0.75em;
}

.VPDoc .vp-doc h2 {
  margin-top: 1.5em;
}

.VPDoc .vp-doc div[class*='language-'] {
  border-radius: 14px;
  margin: 1.2em 0;
}

主要变化:

  • 正文行高从默认的 1.7 调到 1.9:中文排版需要更大的行间距才舒适。
  • 代码块圆角 14px:和卡片的大圆角风格统一。
  • H1 底部间距缩小:默认主题的 H1 下方空白太多,作为文章标题时显得松散。

加密文章在首页的展示

加密文章在首页列表中需要特殊处理。如果直接显示摘要,就泄露了加密文章的内容;如果统一显示「此文章已加密」,又不利于用户判断是否值得解密。

最终的方案是基于 Session 状态动态切换

vue
<p class="post-excerpt">
  <template v-if="post.isPrivate && !sessionValid">
    此文章已加密,请输入密码查看。
  </template>
  <template v-else>
    {{ post.excerpt }}
  </template>
</p>

sessionValidonMounted 中检查 localStorage 是否有有效的解密 Session。如果用户已经在其他文章中输入过正确密码(Session 未过期),首页就显示真实摘要;否则显示加密提示。

响应式适配

在小于 1024px 的屏幕上,栅格布局退化为单列,侧栏从 sticky 变为 static:

css
@media (max-width: 1024px) {
  .home-grid {
    grid-template-columns: 1fr;
    gap: 22px;
  }

  .banner-title {
    font-size: 1.65rem;
  }

  .side-column {
    position: static;
  }
}

文章页也有对应的响应式处理。VitePress 默认主题在 960px 以下会隐藏右侧 Outline,这一行为不需要额外处理。

暗色模式

所有颜色都通过 VitePress 的 CSS 变量引用,暗色模式下自动切换:

  • --vp-c-bg-soft:亮色下为浅灰,暗色下为深灰。
  • --vp-c-text-1/2/3:三级文字颜色,暗色下自动反转。
  • --vp-c-brand-1/2/3:品牌色在暗色模式下略有调整。
  • --vp-c-divider:分割线颜色。

唯一需要注意的是阴影。box-shadow 使用的 rgba(0, 0, 0, ...) 在暗色背景下不明显。但由于卡片有 border,视觉上仍然有足够的层次感,所以没有做暗色模式下的阴影特殊处理。

总结

整个美化过程涉及的文件:

文件作用
BlogHome.vue首页布局、卡片、档案栏、Widget
custom.css布局变量定义、文章页样式覆盖
theme/index.ts引入 custom.css
public/images/avatar.jpg头像资源

核心原则是用 VitePress 给的接口做事:CSS 变量覆盖、Layout Slot、Vue 全局组件。没有 monkey-patch 默认主题的任何 JS 逻辑,没有 fork 主题源码,没有引入第三方 UI 框架。

这意味着 VitePress 版本升级时,只要 CSS 变量名和 Layout Slot 没有 breaking change,美化代码就不需要改动。如果未来出现成熟的 VitePress 博客主题或插件生态,也可以随时替换而不影响文章内容本身。

最后更新于:

Hosted by GitHub Pages