Appearance
Copy Buttons
Add copy-link and copy-markdown buttons to every VitePress page.
When to use
Use this pattern when readers need to quickly share a page URL or reuse its markdown source. The buttons appear before the document content and provide one-click clipboard access to both.
The pattern
Three pieces work together: a transformPageData hook that pre-computes the data at build time, a Vue component that renders the buttons, and a custom layout that slots the component into the page.
Pre-compute page data
In .vitepress/config.js, use transformPageData to attach the full URL and base64-encoded markdown source to every page:
js
import { defineConfig } from "vitepress";
import { readFileSync } from "fs";
import { parse as parsePath } from "path";
export default defineConfig({
base: "/your-site/",
cleanUrls: true,
transformPageData(pageData) {
// Full URL for the copy-link button
const { dir, name } = parsePath(pageData.relativePath);
const cleanPath = dir + (name !== "index" ? `/${name}` : "");
pageData.fullUrl =
`https://yourname.github.io/your-site/${cleanPath}`;
// Raw markdown for the copy-markdown button
try {
const content = readFileSync(pageData.filePath, "utf-8");
pageData.markdownSourceBase64 =
Buffer.from(content).toString("base64");
} catch {
pageData.markdownSourceBase64 = null;
}
},
});Base64 encoding prevents issues with special characters and multiline content in the serialized page data.
Create the CopyButtons component
Create .vitepress/theme/CopyButtons.vue:
vue
<template>
<div class="copy-buttons">
<button
class="inline-copy-btn"
:class="{ copied: linkCopied }"
aria-live="polite"
@mousedown.prevent
@click="copyLink"
>
<span data-state="idle" :aria-hidden="String(linkCopied)">
<LinkIcon />
Copy Link
</span>
<span
data-state="success"
:aria-hidden="String(!linkCopied)"
role="status"
>
<CheckIcon />
Copied!
</span>
</button>
<button
class="inline-copy-btn"
:class="{ copied: markdownCopied }"
aria-live="polite"
@mousedown.prevent
@click="copyMarkdown"
:disabled="!hasMarkdownSource"
>
<span
data-state="idle"
:aria-hidden="String(markdownCopied)"
>
<DocIcon />
Copy Markdown
</span>
<span
data-state="success"
:aria-hidden="String(!markdownCopied)"
role="status"
>
<CheckIcon />
Copied!
</span>
</button>
</div>
</template>
<script setup>
import { ref, computed, h } from "vue";
import { useData } from "vitepress";
const { page } = useData();
const ICON_ATTRS = {
xmlns: "http://www.w3.org/2000/svg",
width: "14",
height: "14",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
};
const LinkIcon = () => h("svg", ICON_ATTRS, [
h("path", {
d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
}),
h("path", {
d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
}),
]);
const CheckIcon = () => h("svg", ICON_ATTRS, [
h("polyline", { points: "20 6 9 17 4 12" }),
]);
const DocIcon = () => h("svg", ICON_ATTRS, [
h("path", {
d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z",
}),
h("polyline", { points: "14 2 14 8 20 8" }),
h("line", { x1: "16", y1: "13", x2: "8", y2: "13" }),
h("line", { x1: "16", y1: "17", x2: "8", y2: "17" }),
h("polyline", { points: "10 9 9 9 8 9" }),
]);
const linkCopied = ref(false);
const markdownCopied = ref(false);
const hasMarkdownSource = computed(
() => Boolean(page.value.markdownSourceBase64),
);
async function copyLink() {
try {
await navigator.clipboard.writeText(page.value.fullUrl);
linkCopied.value = true;
setTimeout(() => linkCopied.value = false, 2000);
} catch (err) {
console.error("Failed to copy link:", err);
}
}
async function copyMarkdown() {
try {
const markdown = atob(page.value.markdownSourceBase64);
await navigator.clipboard.writeText(markdown);
markdownCopied.value = true;
setTimeout(() => markdownCopied.value = false, 2000);
} catch (err) {
console.error("Failed to copy markdown:", err);
}
}
</script>Key design decisions:
- Inline SVG icons via
h()render functions avoid external icon dependencies. - CSS grid state swap — both
idleandsuccessspans occupy the same grid cell. Togglingaria-hiddencontrols visibility without layout shift. aria-live="polite"on the button announces the "Copied!" state to screen readers.@mousedown.preventkeeps the button from taking focus on click. On iOS 15+, a focus change on tap firesbluron the previously focused element, which clears the document's text selection — so a reader who selected text then tapped "Copy Link" would lose their selection. Preventing the mousedown default stops the focus change, so the reader's selection survives. Apply this to every theme button that should leave the user's selection alone; File a Bug Button uses the same treatment.
Style the buttons
Add to .vitepress/theme/custom.css:
css
.copy-buttons {
display: flex;
gap: 0.5em;
margin-bottom: 0.5rem;
}
.inline-copy-btn {
display: inline-grid;
place-items: center;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
padding: 0.2em 0.5em;
font-size: 0.8rem;
color: var(--vp-c-text-1);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.inline-copy-btn [data-state] {
grid-column: 1 / -1;
grid-row: 1 / -1;
visibility: hidden;
display: inline-flex;
align-items: center;
gap: 0.3em;
}
.inline-copy-btn [data-state][aria-hidden="false"] {
visibility: visible;
}
.inline-copy-btn + .inline-copy-btn {
margin-left: 0.5em;
}
.inline-copy-btn:hover {
background: var(--vp-c-bg-alt);
border-color: var(--vp-c-brand);
}
.inline-copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.inline-copy-btn.copied {
background: var(--vp-c-green-soft);
border-color: var(--vp-c-green-1);
color: var(--vp-c-green-1);
}The inline-grid trick stacks both button states in the same cell so the button width stays constant during the idle-to-copied transition.
Integrate into the layout
Create .vitepress/theme/CustomLayout.vue using the doc-before slot:
vue
<template>
<Layout>
<template #doc-before>
<CopyButtons />
</template>
</Layout>
</template>
<script setup>
import DefaultTheme from "vitepress/theme";
import CopyButtons from "./CopyButtons.vue";
const { Layout } = DefaultTheme;
</script>Register the custom layout in .vitepress/theme/index.js:
js
import DefaultTheme from "vitepress/theme";
import CustomLayout from "./CustomLayout.vue";
import "./custom.css";
export default {
extends: DefaultTheme,
Layout: CustomLayout,
};VitePress's doc-before slot places content after the page title but before the document body — ideal for action buttons.
Checks
- [ ] Copy Link button copies the full page URL to the clipboard
- [ ] Copy Markdown button copies the raw markdown source
- [ ] Buttons show "Copied!" feedback for 2 seconds
- [ ] Copy Markdown is disabled when source is unavailable
- [ ] Buttons adapt to light and dark themes via VitePress CSS variables
- [ ] Button width stays constant during the idle/copied transition
- [ ] Build completes without errors (
npm run docs:build)