Skip to main content

Creating a Custom Theme

VitePress allows you to create completely custom themes to match your specific design requirements. A custom theme gives you full control over the layout, styling, and functionality of your documentation site.

Theme Structure

Theme Entry File

Create a theme entry file at .vitepress/theme/index.js or .vitepress/theme/index.ts:
.
├─ docs
│  ├─ .vitepress
│  │  ├─ theme
│  │  │  └─ index.js   # Theme entry
│  │  └─ config.js
│  └─ index.md
└─ package.json
When VitePress detects a theme entry file, it will use your custom theme instead of the default theme.

Theme Interface

A VitePress theme must implement this interface:
interface Theme {
  /**
   * Root layout component for every page
   * @required
   */
  Layout: Component
  
  /**
   * Enhance Vue app instance
   * @optional
   */
  enhanceApp?: (ctx: EnhanceAppContext) => Awaitable<void>
  
  /**
   * Extend another theme, calling its `enhanceApp` before ours
   * @optional
   */
  extends?: Theme
}

interface EnhanceAppContext {
  app: App // Vue app instance
  router: Router // VitePress router instance
  siteData: Ref<SiteData> // Site-level metadata
}

Basic Custom Theme

Minimal Setup

The simplest possible theme:
import Layout from './Layout.vue'

export default {
  Layout
}
The <Content /> component renders the compiled Markdown content.

With Type Safety

.vitepress/theme/index.ts
import type { Theme } from 'vitepress'
import Layout from './Layout.vue'

export default {
  Layout,
  enhanceApp({ app, router, siteData }) {
    // App enhancements
  }
} satisfies Theme

Building a Layout Component

Basic Layout

A layout needs at minimum the <Content /> component:
.vitepress/theme/Layout.vue
<script setup>
import { Content } from 'vitepress'
</script>

<template>
  <div class="layout">
    <header>
      <h1>My Site</h1>
    </header>
    
    <main>
      <Content />
    </main>
    
    <footer>
      <p>© 2024 My Company</p>
    </footer>
  </div>
</template>

<style scoped>
.layout {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}
</style>

Handling 404 Pages

Use the useData() composable to detect 404 pages:
<script setup>
import { useData, Content } from 'vitepress'

const { page } = useData()
</script>

<template>
  <div class="layout">
    <div v-if="page.isNotFound" class="not-found">
      <h1>404</h1>
      <p>Page not found</p>
      <a href="/">Go home</a>
    </div>
    
    <Content v-else />
  </div>
</template>

Multiple Layout Types

Support different layouts via frontmatter:
<script setup>
import { useData, Content } from 'vitepress'
import HomePage from './HomePage.vue'
import DocPage from './DocPage.vue'
import NotFound from './NotFound.vue'

const { page, frontmatter } = useData()
</script>

<template>
  <NotFound v-if="page.isNotFound" />
  <HomePage v-else-if="frontmatter.layout === 'home'" />
  <DocPage v-else />
</template>
Then in your Markdown:
---
layout: home
---

# Home Page Content

Using Runtime Data

Available Composables

VitePress provides several composables for accessing runtime data:
<script setup>
import { useData } from 'vitepress'

const { 
  site,        // Site-level data
  page,        // Current page data
  frontmatter, // Current page frontmatter
  params,      // Dynamic route params
  theme,       // Theme config
  isDark,      // Dark mode state
  lang,        // Current language
  localeIndex, // Current locale index
  title,       // Page title
  description  // Page description
} = useData()
</script>

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ description }}</p>
    <p>Theme: {{ isDark ? 'Dark' : 'Light' }}</p>
  </div>
</template>

Accessing Page Data

<script setup>
import { useData } from 'vitepress'

const { page, frontmatter } = useData()
</script>

<template>
  <article>
    <h1>{{ page.title }}</h1>
    
    <div class="meta">
      <span v-if="frontmatter.author">By {{ frontmatter.author }}</span>
      <span v-if="frontmatter.date">{{ frontmatter.date }}</span>
      <span v-if="page.lastUpdated">
        Updated: {{ new Date(page.lastUpdated).toLocaleDateString() }}
      </span>
    </div>
    
    <Content />
  </article>
</template>

Enhancing the App

Registering Global Components

.vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import MyButton from './components/MyButton.vue'
import MyCard from './components/MyCard.vue'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    app.component('MyButton', MyButton)
    app.component('MyCard', MyCard)
  }
}
Now use them in Markdown:
# My Page

<MyButton>Click me</MyButton>

<MyCard title="Card Title">
  Card content
</MyCard>

Auto-Registering Components

Use Vite’s glob import:
.vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    // Auto-register all components from ./components
    const components = import.meta.glob('./components/**/*.vue', { eager: true })
    
    for (const path in components) {
      const component = components[path]
      const name = path.match(/\/([^\/]+)\.vue$/)?.[1]
      if (name) {
        app.component(name, component.default)
      }
    }
  }
}

Installing Vue Plugins

.vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import VueGtag from 'vue-gtag'

export default {
  extends: DefaultTheme,
  enhanceApp({ app, router }) {
    app.use(VueGtag, {
      config: { id: 'GA_MEASUREMENT_ID' }
    }, router)
  }
}

Adding Global State

.vitepress/theme/index.js
import { createPinia } from 'pinia'
import DefaultTheme from 'vitepress/theme'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    const pinia = createPinia()
    app.use(pinia)
  }
}

SSR Compatibility

Your theme must be SSR-compatible. Avoid accessing browser-only APIs during SSR.

Conditional Browser Code

<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  // Safe to use browser APIs here
  console.log(window.location.href)
  document.title = 'My Page'
})
</script>

Import Guards

if (typeof window !== 'undefined') {
  // Browser-only imports
  const analytics = await import('./analytics')
  analytics.init()
}

Theme Styling

Scoped Styles

<template>
  <div class="custom-layout">
    <Content />
  </div>
</template>

<style scoped>
.custom-layout {
  font-family: 'Inter', sans-serif;
  line-height: 1.6;
}
</style>

Global Styles

.vitepress/theme/index.js
import Layout from './Layout.vue'
import './styles/global.css'
import './styles/variables.css'

export default {
  Layout
}
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: system-ui, -apple-system, sans-serif;
}

code {
  font-family: 'Monaco', monospace;
  background: #f5f5f5;
  padding: 2px 6px;
  border-radius: 4px;
}

CSS Variables

:root {
  --color-primary: #3b82f6;
  --color-text: #1f2937;
  --color-background: #ffffff;
  --spacing-unit: 8px;
}

.dark {
  --color-text: #f9fafb;
  --color-background: #111827;
}

Distributing Your Theme

As npm Package

  1. Structure your package:
my-vitepress-theme/
├─ src/
│  ├─ Layout.vue
│  ├─ index.ts
│  └─ components/
├─ package.json
└─ README.md
  1. Export the theme:
src/index.ts
import type { Theme } from 'vitepress'
import Layout from './Layout.vue'

export type { ThemeConfig } from './config'

const theme: Theme = {
  Layout,
  enhanceApp({ app }) {
    // Theme enhancements
  }
}

export default theme
  1. Package.json:
{
  "name": "my-vitepress-theme",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./config": "./config.ts"
  },
  "files": ["src", "config.ts"],
  "peerDependencies": {
    "vitepress": "^1.0.0",
    "vue": "^3.0.0"
  }
}
  1. Document usage:
README.md
# My VitePress Theme

## Installation

\`\`\`bash
npm install my-vitepress-theme
\`\`\`

## Usage

\`\`\`ts .vitepress/theme/index.ts
import Theme from 'my-vitepress-theme'

export default Theme
\`\`\`

## Configuration

\`\`\`ts .vitepress/config.ts
import { defineConfig } from 'vitepress'
import type { ThemeConfig } from 'my-vitepress-theme'

export default defineConfig<ThemeConfig>({
  themeConfig: {
    // Theme-specific options
  }
})
\`\`\`

As GitHub Template

Create a template repository with:
vitepress-theme-template/
├─ docs/
│  ├─ .vitepress/
│  │  ├─ theme/
│  │  │  ├─ Layout.vue
│  │  │  ├─ index.ts
│  │  │  └─ components/
│  │  └─ config.ts
│  └─ index.md
├─ package.json
└─ README.md
Users can then use “Use this template” to create their own repository.

Advanced Examples

Blog Theme

.vitepress/theme/Layout.vue
<script setup>
import { useData, useRoute } from 'vitepress'
import { data as posts } from './posts.data'

const { frontmatter, page } = useData()
const route = useRoute()

const isBlogPost = route.path.startsWith('/blog/')
</script>

<template>
  <div class="blog-layout">
    <header>
      <nav>
        <a href="/">Home</a>
        <a href="/blog/">Blog</a>
        <a href="/about/">About</a>
      </nav>
    </header>
    
    <main>
      <article v-if="isBlogPost" class="post">
        <h1>{{ page.title }}</h1>
        <div class="meta">
          <span>{{ frontmatter.author }}</span>
          <time>{{ frontmatter.date }}</time>
        </div>
        <Content />
      </article>
      
      <Content v-else />
    </main>
  </div>
</template>

Documentation Theme with Sidebar

.vitepress/theme/Layout.vue
<script setup>
import { useData } from 'vitepress'
import Sidebar from './Sidebar.vue'
import Navbar from './Navbar.vue'

const { theme, page } = useData()
</script>

<template>
  <div class="doc-layout">
    <Navbar />
    
    <div class="container">
      <Sidebar v-if="theme.sidebar" :items="theme.sidebar" />
      
      <main class="content">
        <article>
          <h1>{{ page.title }}</h1>
          <Content />
        </article>
      </main>
    </div>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  max-width: 1400px;
  margin: 0 auto;
}

.content {
  flex: 1;
  padding: 2rem;
  max-width: 800px;
}
</style>

Complete Theme Example

.vitepress/theme/index.ts
import type { Theme } from 'vitepress'
import Layout from './Layout.vue'
import './styles/vars.css'
import './styles/base.css'

// Components
import Button from './components/Button.vue'
import Card from './components/Card.vue'
import Badge from './components/Badge.vue'

export interface ThemeConfig {
  navbar: {
    logo?: string
    items: NavItem[]
  }
  sidebar: SidebarConfig
  footer?: {
    message?: string
    copyright?: string
  }
}

const theme: Theme = {
  Layout,
  enhanceApp({ app, router, siteData }) {
    // Register global components
    app.component('Button', Button)
    app.component('Card', Card)
    app.component('Badge', Badge)
    
    // Add router hooks
    router.onBeforeRouteChange = (to) => {
      console.log('Navigating to', to)
    }
    
    // Global properties
    app.config.globalProperties.$myThemeVersion = '1.0.0'
  }
}

export default theme

Next Steps

Extending Default Theme

Build on top of the default theme instead

Runtime API

Explore available composables and utilities

Build docs developers (and LLMs) love