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 content

Everything 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:

.docs/components/Hello.vue
<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 fromWhat'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/routeruseRoute, 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:

.docs/shims.d.ts
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 .md file 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

.docs/components/TechCard.vue
<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

ComponentImport path
Buttonundocs/src/app/components/ui/Button.vue
ButtonGroupundocs/src/app/components/ui/ButtonGroup.vue
Avatarundocs/src/app/components/ui/Avatar.vue
Bannerundocs/src/app/components/ui/Banner.vue
Breadcrumbundocs/src/app/components/ui/Breadcrumb.vue
DropdownMenuundocs/src/app/components/ui/DropdownMenu.vue
Separatorundocs/src/app/components/ui/Separator.vue
Skeletonundocs/src/app/components/ui/Skeleton.vue
Tooltipundocs/src/app/components/ui/Tooltip.vue
Containerundocs/src/app/components/Container.vue
Iconundocs/src/app/components/global/Icon.vue
AppLinkundocs/src/app/components/app/AppLink

Page layout scaffold

ComponentImport path
Mainundocs/src/app/components/layout/Main.vue
Pageundocs/src/app/components/layout/Page.vue
PageHeaderundocs/src/app/components/layout/PageHeader.vue
PageHeaderLinksundocs/src/app/components/layout/PageHeaderLinks.vue
PageBodyundocs/src/app/components/layout/PageBody.vue
PageAsideundocs/src/app/components/layout/PageAside.vue
PageAnchorsundocs/src/app/components/layout/PageAnchors.vue
PageLinksundocs/src/app/components/layout/PageLinks.vue

Landing blocks

ComponentImport path
PageHeroundocs/src/app/components/blocks/PageHero.vue
PageSectionundocs/src/app/components/blocks/PageSection.vue
PageFeatureundocs/src/app/components/blocks/PageFeature.vue
PageCardundocs/src/app/components/blocks/PageCard.vue
PageSponsorsundocs/src/app/components/blocks/PageSponsors.vue
PageContributorsundocs/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.

ComponentImport path
GridSystemundocs/src/app/components/grid/GridSystem.vue
Gridundocs/src/app/components/grid/Grid.vue
GridCellundocs/src/app/components/grid/GridCell.vue
GridCrossundocs/src/app/components/grid/GridCross.vue
GridPageundocs/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

ComponentImport path
MarkdownRendererundocs/src/app/content/MarkdownRenderer
DocsTocundocs/src/app/components/docs/DocsToc.vue
DocsSurroundundocs/src/app/components/docs/DocsSurround.vue
DocsNavigationundocs/src/app/components/docs/DocsNavigation.vue
DocsSearchundocs/src/app/components/docs/DocsSearch.vue
DocsSearchButtonundocs/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:

BlockReachable 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:

.docs/components/Sponsors.vue
<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:

FileMatches
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:

.docs/pages/showcase.vue
<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 />:

.docs/layouts/marketing.vue
<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:

.docs/utils/definePageMeta.ts
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:

UtilityUse for
text-foregroundprimary body text
text-muted-foregroundsecondary / muted text
text-brandaccented text (your themeColor)
bg-backgroundpage background
bg-cardraised surfaces / cards
bg-mutedsubtle surface fills
bg-accenthover fills
bg-primary + text-primary-foregroundthe high-contrast button (black/white)
border-borderborders and dividers
ring-ringfocus 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:

RoleUtilities
infotext-info · bg-info-tint · border-info-border
successtext-success · bg-success-tint · border-success-border
warningtext-warning · bg-warning-tint · border-warning-border
dangertext-danger · bg-danger-tint · border-danger-border
importanttext-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:

.docs
components
layouts
pages
index.vue
utils
<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.