Appearance
Breadcrumbs
Render a breadcrumb trail above each page title, derived from the page's ancestor directories, with each crumb linking where it can — to a section's own page, or failing that, to the matching heading anchor in an ancestor page.
When to use
Use this pattern when your content is organized into nested directories and readers benefit from seeing — and navigating — where the current page sits in the hierarchy. For a page at skills/web/vitepress/search, the trail shows:
Skills / Web / Vitepress
Skills and Web link to their index pages. Vitepress is a grouping directory with no page of its own, but the Web index has a ## VitePress section, so the crumb links to that heading anchor (/skills/web/#vitepress). A grouping crumb with no matching section anywhere up the tree renders as plain text. The trail is computed at build time from the file system, so it stays correct as you add or move pages without any per-page configuration.
The pattern
Two pieces work together: a build-time hook that attaches a breadcrumbs array to each page's data, and a layout slot that renders the trail above the page title.
Compute the trail in transformPageData
VitePress's transformPageData hook runs once per page at build time and can attach arbitrary data to pageData. Walk the page's ancestor directories under the content root and build a crumb for each.
In .vitepress/config.js:
js
import { existsSync, readFileSync } from "node:fs";
import matter from "gray-matter";
/**
* Title-case a single path segment, splitting on `-` and `.` word boundaries.
* `browser-automation` → `Browser Automation`.
*/
function titleize(segment) {
return segment
.split(/[-.]/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
/**
* The display title for a directory, matching what the sidebar shows: the
* `metadata.title` from its SKILL.md, falling back to the formatted dir name.
*/
function skillTitle(skillDir) {
try {
const { data } = matter(readFileSync(`${skillDir}/SKILL.md`, "utf-8"));
return data.metadata?.title || titleize(skillDir.split("/").pop());
} catch {
return titleize(skillDir.split("/").pop());
}
}
/**
* Whether a directory renders its own page (a SKILL.md or index.md), and so
* can be linked at its directory URL. Grouping directories that only hold
* loose markdown files have no such page.
*/
function dirHasPage(dir) {
return existsSync(`${dir}/SKILL.md`) || existsSync(`${dir}/index.md`);
}
// VitePress's heading-anchor slugify, copied verbatim from its markdown
// renderer so the slugs we compute match the `id`s it emits on headings.
const rControl = /[�-]/g;
const rCombining = /[̀-ͯ]/g;
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
function slugify(str) {
return str
.normalize("NFKD")
.replace(rCombining, "")
.replace(rControl, "")
.replace(rSpecial, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/^(\d)/, "_$1")
.toLowerCase();
}
/**
* The heading-anchor slugs in a directory's page, matching the `id`s VitePress
* assigns to headings. Lets a grouping crumb resolve to a section anchor in an
* ancestor page (the `Vitepress` crumb → `/skills/web/#vitepress`).
*/
function pageHeadingSlugs(dir) {
const file = existsSync(`${dir}/SKILL.md`) ?
`${dir}/SKILL.md` :
existsSync(`${dir}/index.md`) ?
`${dir}/index.md` :
null;
const slugs = new Set();
if (!file) return slugs;
const body = matter(readFileSync(file, "utf-8")).content
.replace(/```[\s\S]*?```/g, ""); // ignore `##` lines inside code fences
for (const line of body.split("\n")) {
const match = line.match(/^#{1,6}\s+(.*?)\s*$/);
if (match) slugs.add(slugify(match[1].replace(/`/g, "")));
}
return slugs;
}
/**
* Build the breadcrumb trail above a page title. The trail starts at the
* `Skills` listing, then walks the ancestor directories of the page under
* `skills/`, excluding the page itself.
*
* `skills/web/vitepress/search.md` → [Skills, Web, Vitepress]
*/
function buildBreadcrumbs(relativePath) {
const parts = relativePath.split("/");
if (parts[0] !== "skills") return [];
const fileName = parts[parts.length - 1];
let dirSegments = parts.slice(1, -1);
// SKILL.md and index.md render at their directory URL, so the final dir
// segment is the page itself, not an ancestor — drop it.
if (fileName === "SKILL.md" || fileName === "index.md") {
dirSegments = dirSegments.slice(0, -1);
}
// Index pages have no ancestor trail to show; skip the lone `Skills` root.
if (dirSegments.length === 0) return [];
// Root crumb: the skills listing at `skills/index.md`.
const crumbs = [{ text: "Skills", link: "/skills/" }];
let dir = "skills";
// The nearest ancestor that renders its own page; a grouping crumb anchors
// into this page's section heading when one matches.
let lastPageDir = "skills";
for (const segment of dirSegments) {
dir = `${dir}/${segment}`;
let link = null;
if (dirHasPage(dir)) {
link = `/${dir}/`;
lastPageDir = dir;
} else if (pageHeadingSlugs(lastPageDir).has(segment)) {
link = `/${lastPageDir}/#${segment}`;
}
crumbs.push({ text: skillTitle(dir), link });
}
return crumbs;
}
export default defineConfig({
async transformPageData(pageData) {
// ...other per-page data...
pageData.breadcrumbs = buildBreadcrumbs(pageData.relativePath);
},
});Key design decisions:
- Computed at build time, not in the browser.
transformPageDataalready runs once per page (it's where Copy Buttons derivesfullUrl), so deriving the trail there costs nothing at runtime and the client ships a ready-made array. - Titles match the sidebar.
skillTitle()reads the samemetadata.titlethe sidebar uses (see Auto Nav), so a crumb never disagrees with the nav label for the same section — abbreviations and proper nouns stay correct instead of being naively title-cased. - Link to a page, else a section anchor, else nothing. A directory with its own page links to its directory URL. A grouping directory (only loose markdown, like
vitepress/) has no landing page, so instead of giving up, the crumb looks for a section heading in the nearest ancestor page whose slug matches the directory name and links to that anchor (/skills/web/#vitepress). Only when no such heading exists does the crumb fall back to plain text. The slug match uses VitePress's ownslugify, so the anchor is guaranteed to be a realidon the target page — never a 404 or a dead#fragment. - The page itself is never a crumb. For directory-index pages (
SKILL.md/index.md, which render at the directory URL) the final path segment is the page, so it's dropped from the trail. Index pages and pages outsideskills/get an empty array and render no trail at all.
Render the trail in the layout
In your CustomLayout.vue, read page.breadcrumbs from useData() and render it in the #doc-before slot. Place the trail last in that slot — after any action buttons or page metadata — so it sits directly above the page <h1>. Use withBase() so links respect the site's base path.
vue
<template>
<Layout>
<template #doc-before>
<!-- ...action buttons, page metadata... -->
<nav
v-if="page.breadcrumbs && page.breadcrumbs.length"
class="breadcrumbs"
aria-label="Breadcrumb"
>
<template v-for="(crumb, index) in page.breadcrumbs" :key="index">
<span v-if="index > 0" class="breadcrumbs-separator">/</span>
<a v-if="crumb.link" :href="withBase(crumb.link)">{{ crumb.text }}</a>
<span v-else>{{ crumb.text }}</span>
</template>
</nav>
</template>
</Layout>
</template>
<script setup>
import DefaultTheme from "vitepress/theme";
import { useData, withBase } from "vitepress";
const { Layout } = DefaultTheme;
const { page } = useData();
</script>Design notes:
aria-label="Breadcrumb"on the<nav>is the standard landmark announced to assistive tech, so the trail is exposed as navigation rather than loose links.- Separators are not crumbs. The
/is rendered between items (index > 0) as a non-selectable span, keeping it out of the link text and the accessible name of each crumb. - Linked vs. plain crumbs are decided entirely by the data: a crumb with a
linkbecomes an<a>, one without becomes a<span>. The template carries no path logic.
Style the trail
Add to .vitepress/theme/custom.css:
css
.breadcrumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4em;
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.breadcrumbs a {
color: var(--vp-c-text-2);
font-weight: 500;
text-decoration: none;
}
.breadcrumbs a:hover {
color: var(--vp-c-brand-1);
text-decoration: underline;
}
.breadcrumbs-separator {
color: var(--vp-c-divider);
user-select: none;
}The trail uses the muted --vp-c-text-2 so it reads as secondary to the title, and flex-wrap lets long trails wrap on narrow screens. All colors are VitePress theme variables, so the trail adapts to light and dark mode for free.
Behavior across page types
A [bracketed] crumb links; plain text does not. A crumb links to its own page when it has one, else to a matching #section anchor in the nearest ancestor page, else nothing.
| Page | Breadcrumb |
|---|---|
skills/web/vitepress/search | [Skills] / [Web] / [Vitepress] |
skills/web/vue/composables/use-form | [Skills] / [Web] / [Vue] / Composables |
skills/git/worktrees | [Skills] / [Git] |
skill index (/skills/web/) | none |
skills listing (/skills/) | none |
home (/) | none |
[Vitepress] links to /skills/web/#vitepress and [Vue] to /skills/web/#vue — section anchors in the Web index. Composables stays plain text because the Web index has no ## Composables section to anchor to.
Checks
- [ ] Trail appears above the title on nested pages (
skills/web/vitepress/search→ "Skills / Web / Vitepress") - [ ] Crumbs for sections with an index page link to that page; grouping directories link to a matching
#sectionanchor in an ancestor page when one exists, and render as plain text only when none does - [ ] Section-anchor crumbs resolve to a real heading
id(no dead#fragment), since the slug uses VitePress's ownslugify - [ ] Crumb labels match the sidebar labels for the same section
- [ ] Skill index pages, the skills listing, and the home page show no trail
- [ ] Links respect the site base path (work under a subpath deploy)
- [ ] Trail adapts to light and dark themes via VitePress CSS variables
- [ ] Build completes without errors (
npm run docs:build)