Custom Theme
Ship your own Vue components, pages, and layouts from a .docs/ directory to extend the undocs app — a landing page, custom sections, or a fully bespoke docs experience.
Most docs sites need nothing but Markdown and a config file. But when you want a
custom landing page, a bespoke component in your Markdown, or a whole new page
layout, undocs lets a docs project ship its own Vue. Drop a .docs/ directory
next to your Markdown and its components, pages, and layouts load straight into
the running app.
#The .docs/ directory
Create a .docs/ folder alongside your Markdown. undocs discovers three special
subdirectories:
my-docs/
.config/
docs.yaml # your site config
.docs/ # ← the theme layer
components/ # Vue components (usable from Markdown + your pages)
pages/ # file-based routes, layered over the built-ins
layouts/ # named layouts a page can select
public/ # static assets served at the site root
1.guide/
1.index.md # your Markdown contentEverything under .docs/{components,pages,layouts} is compiled into the app at
build time. .docs/public/ is served as static files. Any other folder (e.g. a
utils/ for shared helpers) is just plain source you import with a relative
path.
Note
.docs/ is watched in dev — adding or removing a component, page, or layout
triggers a reload, and editing one hot-updates in place.
#Importing undocs internals
undocs ships its own source as a public subpath export, so your theme files
import Vue, undocs composables, the router, and undocs' components from the
undocs/src/app/* path:
<script setup lang="ts">
import { ref } from "vue";
import Button from "undocs/src/app/components/ui/Button.vue";
import Icon from "undocs/src/app/components/global/Icon.vue";
const count = ref(0);
</script>
<template>
<Button color="primary" @click="count++">
<Icon name="i-lucide-plus" /> Clicked {{ count }}
</Button>
</template>Important
There are no auto-imports. Every .docs/** file imports Vue reactivity,
undocs composables, the router, and components explicitly. This is by
design — nothing is magically in scope.
The import roots you'll use:
| Import from | What's there |
|---|---|
undocs/src/app/components/** | undocs' shipped UI, layout, block, and docs components |
undocs/src/app/content/** | the Markdown renderer and prose components |
undocs/src/app/composables/** | useAsyncData, useAppConfig, usePageSEO, queryPage, … |
undocs/src/app/router | useRoute, useRouter, AppLink |
#Editor types
Because the export points at real source, type-checking and editor completions
work once your .vue imports have an ambient module declaration. Add a shim to
your docs project:
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}#Components
Any .vue file under .docs/components/ is loaded and registered twice:
- into the Markdown renderer, so you can use it from any
.mdfile by its PascalCase name or its kebab-case tag; - globally on the app, so your own pages and layouts can use it by name without importing.
A component file named TechCard.vue is registered as both TechCard and
tech-card. Nested folders don't affect the name — only the file's basename is
used.
Note
Built-in components win on a name clash. Naming a file Alert.vue or Button.vue
won't shadow undocs' own — pick a distinct name.
#Example
<script setup lang="ts">
import Icon from "undocs/src/app/components/global/Icon.vue";
defineProps<{ icon?: string; title?: string }>();
</script>
<template>
<div class="rounded-xl border border-border bg-card p-6">
<Icon v-if="icon" :name="icon" class="size-6 text-brand" />
<h3 class="mt-3 font-semibold text-foreground">{{ title }}</h3>
<p class="mt-1 text-sm text-muted-foreground"><slot /></p>
</div>
</template>Use it from Markdown with the kebab tag. Props go in {}, the default slot is
the block body:
::tech-card{icon="i-lucide-rocket" title="Fast"}
Built on Vite and Nitro for instant HMR.
::Or from one of your own pages/layouts, by name — no import needed:
<template>
<TechCard icon="i-lucide-rocket" title="Fast">Built on Vite.</TechCard>
</template>#The undocs component kit
Build on undocs' own components instead of reinventing them. All live under
undocs/src/app/components/**.
Primitives
| Component | Import path |
|---|---|
Button | undocs/src/app/components/ui/Button.vue |
ButtonGroup | undocs/src/app/components/ui/ButtonGroup.vue |
Avatar | undocs/src/app/components/ui/Avatar.vue |
Banner | undocs/src/app/components/ui/Banner.vue |
Breadcrumb | undocs/src/app/components/ui/Breadcrumb.vue |
DropdownMenu | undocs/src/app/components/ui/DropdownMenu.vue |
Separator | undocs/src/app/components/ui/Separator.vue |
Skeleton | undocs/src/app/components/ui/Skeleton.vue |
Tooltip | undocs/src/app/components/ui/Tooltip.vue |
Container | undocs/src/app/components/Container.vue |
Icon | undocs/src/app/components/global/Icon.vue |
AppLink | undocs/src/app/components/app/AppLink |
Page layout scaffold
| Component | Import path |
|---|---|
Main | undocs/src/app/components/layout/Main.vue |
Page | undocs/src/app/components/layout/Page.vue |
PageHeader | undocs/src/app/components/layout/PageHeader.vue |
PageHeaderLinks | undocs/src/app/components/layout/PageHeaderLinks.vue |
PageBody | undocs/src/app/components/layout/PageBody.vue |
PageAside | undocs/src/app/components/layout/PageAside.vue |
PageAnchors | undocs/src/app/components/layout/PageAnchors.vue |
PageLinks | undocs/src/app/components/layout/PageLinks.vue |
Landing blocks
| Component | Import path |
|---|---|
PageHero | undocs/src/app/components/blocks/PageHero.vue |
PageSection | undocs/src/app/components/blocks/PageSection.vue |
PageFeature | undocs/src/app/components/blocks/PageFeature.vue |
PageCard | undocs/src/app/components/blocks/PageCard.vue |
PageSponsors | undocs/src/app/components/blocks/PageSponsors.vue |
PageContributors | undocs/src/app/components/blocks/PageContributors.vue |
Grid — the Geist grid: a cell-and-guide
layout whose rule lines are part of the design. Grid takes columns/rows
either as a number or per breakpoint ({ sm, md, lg } — mobile / tablet /
desktop); GridCell takes Geist's span notation (column="1/3") plus solid
to occlude the guides behind an opaque cell. Guides only render inside a
GridSystem.
| Component | Import path |
|---|---|
GridSystem | undocs/src/app/components/grid/GridSystem.vue |
Grid | undocs/src/app/components/grid/Grid.vue |
GridCell | undocs/src/app/components/grid/GridCell.vue |
GridCross | undocs/src/app/components/grid/GridCross.vue |
GridPage | undocs/src/app/components/grid/GridPage.vue |
Note
Reach for Grid on landing pages and feature breakdowns, where the guides are
the visual language. For ordinary responsive content a Tailwind grid grid-cols-* is the right tool — and if you find yourself hiding both guide
axes, that is the signal to use one.
Docs chrome & the renderer
| Component | Import path |
|---|---|
MarkdownRenderer | undocs/src/app/content/MarkdownRenderer |
DocsToc | undocs/src/app/components/docs/DocsToc.vue |
DocsSurround | undocs/src/app/components/docs/DocsSurround.vue |
DocsNavigation | undocs/src/app/components/docs/DocsNavigation.vue |
DocsSearch | undocs/src/app/components/docs/DocsSearch.vue |
DocsSearchButton | undocs/src/app/components/docs/DocsSearchButton.vue |
Note
Button's size is xs, sm, md, or lg (there is no xl), and color is
primary, neutral, or white. Always check a component's own props rather than
assuming a Nuxt UI signature.
#Markdown blocks & MDC
undocs' Markdown renderer already ships a set of components you reach from any
.md file via MDC
syntax — no theme code required:
| Block | Reachable as |
|---|---|
| Callouts | ::note, ::tip, ::important, ::warning, ::caution |
| Code group | ::code-group (or adjacent fenced blocks — see Content Transform) |
| Code tree | ::code-tree |
| Tabs | ::tabs / ::tab |
| Steps | ::steps (or a numbered list) |
| Landing hero | ::page-hero |
| Landing sections | ::page-section, ::page-feature, ::page-card |
Your own .docs/components/** are merged into this same registry, so a
::my-block tag resolves to your component (built-ins still win on a clash).
#Named slots
A #name block inside an container maps to the component's matching Vue
named slot; everything else becomes the default slot. This is how
::page-hero fills its title, description, and links slots from Markdown:
::page-hero{orientation="horizontal"}
#title
Build [Amazing]{.text-brand} Docs
#description
Ship your Markdown with a landing page that looks like your product.
#links
:read-more{to="/guide" title="Get started"}
::The same works for any component you write — a #footer block routes to a
<slot name="footer" />.
#Async blocks need a <Suspense> wrapper
The data-driven blocks PageSponsors and PageContributors fetch during setup,
so they aren't registered for Markdown directly. Wrap one in a thin .docs
component with a local <Suspense> so it renders on the server:
<script setup lang="ts">
import { Suspense } from "vue";
import PageSponsors from "undocs/src/app/components/blocks/PageSponsors.vue";
</script>
<template>
<Suspense>
<PageSponsors />
</Suspense>
</template>Then use ::sponsors in your Markdown. (Sponsor data comes from the sponsors.api
option in your docs.yaml.)
#Pages
Every .vue file under .docs/pages/ becomes a route, layered over the
built-in routes so a user page always wins:
| File | Matches |
|---|---|
pages/index.vue | / — overrides the default landing |
pages/showcase.vue | /showcase |
pages/team/[id].vue | /team/:anything |
pages/examples/[...slug].vue | /examples and anything below it |
Route params are not extracted — read route.path directly, the same way the
built-in docs page does:
<script setup lang="ts">
import { useRoute } from "undocs/src/app/router";
import { queryPage, queryNavigation } from "undocs/src/app/composables/useContent";
import { useAsyncData } from "undocs/src/app/composables/useAsyncData";
import Page from "undocs/src/app/components/layout/Page.vue";
import PageHeader from "undocs/src/app/components/layout/PageHeader.vue";
import PageBody from "undocs/src/app/components/layout/PageBody.vue";
import MarkdownRenderer from "undocs/src/app/content/MarkdownRenderer";
const route = useRoute();
// Fetch content over the same `/api/docs/*` boundary your Markdown pages use.
const { data: page } = await useAsyncData(route.path, () => queryPage(route.path));
const { data: nav } = await useAsyncData("nav", () => queryNavigation());
</script>
<template>
<Page>
<PageHeader title="Showcase" />
<PageBody>
<MarkdownRenderer v-if="page" :value="page" />
</PageBody>
</Page>
</template>Tip
queryPage(path) returns a single page (or null on a miss) with its rendered
body; queryNavigation() returns the nav tree. Both are plain fetches to the
content API, so they prerender cleanly. Wrap them in useAsyncData so the result
is seeded into the payload and not re-fetched on the client.
#Layouts & definePageMeta
A .vue file under .docs/layouts/ is a named layout, keyed by its lower-cased
basename. A layout renders its page through the default <slot />:
<script setup lang="ts">
import Container from "undocs/src/app/components/Container.vue";
</script>
<template>
<Container>
<slot />
</Container>
</template>A page opts into a layout with definePageMeta. undocs resolves the layout at
build time by statically reading this call, so the layout is known before the
server render — write the layout value as a plain string literal. title and
description are applied at runtime through unhead:
definePageMeta({
layout: "marketing",
title: "Showcase",
description: "Sites built with undocs.",
});Because there are no auto-imports, provide definePageMeta as a small helper in
your theme and import it in each page:
import { useSeoMeta } from "@unhead/vue";
import { usePageSEO } from "undocs/src/app/composables/usePageSEO";
export interface PageMeta {
/** Resolved at build time by undocs; ignored at runtime. */
layout?: string;
title?: string;
description?: string;
}
export function definePageMeta(meta: PageMeta): void {
if (meta.title !== undefined) {
usePageSEO({ title: meta.title, description: meta.description ?? "" });
} else if (meta.description !== undefined) {
useSeoMeta({ description: meta.description });
}
}Note
The runtime helper deliberately ignores layout: AppLayout picks the layout
before the page's <script setup> runs, so switching it at runtime would break
SSR/hydration. The build-time static read is the single source of truth.
The built-in layouts are docs and blog. A .docs/layouts/docs.vue overrides
the default docs layout for the whole site.
#Styling
undocs uses Tailwind v4 with the Geist design system. Style your components with the semantic tokens below — they resolve to Geist's palette and adapt to light/dark automatically:
| Utility | Use for |
|---|---|
text-foreground | primary body text |
text-muted-foreground | secondary / muted text |
text-brand | accented text (your themeColor) |
bg-background | page background |
bg-card | raised surfaces / cards |
bg-muted | subtle surface fills |
bg-accent | hover fills |
bg-primary + text-primary-foreground | the high-contrast button (black/white) |
border-border | borders and dividers |
ring-ring | focus rings |
Note
Geist is monochrome: text-primary / bg-primary are near-black on white
and near-white on black, not your theme color. Your themeColor lives in
text-brand / bg-brand. If you want something tinted with the project's
accent, reach for brand.
The default themeColor is mono, so text-brand reads in body color until
you pick a hue — set themeColor to blue, red,
amber, green, teal, purple, pink, or any CSS color to tint it.
#Status colors
Five status roles cover callouts, alerts, and banners. Each is a triple —
text, the tint it sits on, and a border — and all three flip in dark mode
together, so none of them needs a dark: variant:
| Role | Utilities |
|---|---|
info | text-info · bg-info-tint · border-info-border |
success | text-success · bg-success-tint · border-success-border |
warning | text-warning · bg-warning-tint · border-warning-border |
danger | text-danger · bg-danger-tint · border-danger-border |
important | text-important · bg-important-tint · border-important-border |
<div class="rounded-lg border border-warning-border bg-warning-tint px-4 py-3 text-warning">
Heads up.
</div>Every role clears WCAG AA on its own tint and on the page, in both modes.
#Where brand text may sit
text-brand is derived to clear AA on the page, on a card, and on a
wash of itself (bg-brand/10, bg-brand/15) — and nowhere else. Putting it
on a neutral fill drops it below AA:
<!-- ✅ an active / selected item -->
<a class="rounded-md bg-brand/10 px-3 text-brand">Guide</a>
<!-- ❌ measures ~3.9:1 -->
<a class="rounded-md bg-accent px-3 text-brand">Guide</a>#Type and materials
Geist's type scale is available as text-heading-{14,16,20,24,32},
text-copy-{14,16}, text-label-12, and text-button-{12,14,16}; each presets
font-size, line-height, letter-spacing, and weight together.
Elevation comes from shadow-small for on-page surfaces and
shadow-{tooltip,menu,modal} for floating ones.
Warning
Nuxt UI class names such as border-default, bg-elevated, text-default,
text-highlighted, and text-dimmed are not defined in undocs — they
generate no CSS and fail silently (missing borders, near-invisible text). Use the
tokens above instead. Note especially that text-muted is a surface color, so
it's invisible as text — reach for text-muted-foreground.
Tailwind scans your .docs/** component files automatically, so any utility
class you write there is generated.
#A complete minimal example
A component, a layout, and a page that uses both:
<script setup lang="ts">
import { definePageMeta } from "../utils/definePageMeta";
// `TechCard` is globally registered from `.docs/components` — no import needed.
definePageMeta({
layout: "marketing",
title: "Home",
description: "A custom undocs landing page.",
});
</script>
<template>
<div class="py-16 text-center">
<h1 class="text-4xl font-bold text-foreground">My Project</h1>
<p class="mt-3 text-muted-foreground">Docs with a custom landing page.</p>
<div class="mt-10 grid gap-4 md:grid-cols-3">
<TechCard icon="i-lucide-zap" title="Fast">Instant HMR.</TechCard>
<TechCard icon="i-lucide-globe" title="Agnostic">Deploy anywhere.</TechCard>
<TechCard icon="i-lucide-feather" title="Minimal">Zero overhead.</TechCard>
</div>
</div>
</template>Run undocs dev and your custom landing renders at /, using the marketing
layout and the globally-registered TechCard.