Appearance
File a Bug Button
Add a "File a Bug" button to every VitePress page that opens the GitHub issue form with context pre-filled.
When to use
Use this pattern when you want readers to report issues directly from the documentation. The button captures the current page URL and any selected text, so the filed issue includes context without the reporter having to copy it manually.
The pattern
Two pieces work together: a Vue component that builds the GitHub issue URL and a layout slot that places the button alongside other page actions.
Create the FileABug component
Create .vitepress/theme/FileABug.vue:
vue
<template>
<button
class="inline-copy-btn file-a-bug-btn"
@mousedown.prevent
@click="fileABug"
>
<BugIcon />
File a Bug
</button>
</template>
<script setup>
import { 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 BugIcon = () => h("svg", ICON_ATTRS, [
h("path", { d: "m8 2 1.88 1.88" }),
h("path", { d: "M14.12 3.88 16 2" }),
h("path", {
d: "M9 7.13v-1a3.003 3.003 0 1 1 6 0v1",
}),
h("path", {
d: "M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1"
+ " 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6",
}),
h("path", { d: "M12 20v-9" }),
h("path", { d: "M6.53 9C4.6 8.8 3 7.1 3 5" }),
h("path", { d: "M6 13H2" }),
h("path", {
d: "M3 21c0-2.1 1.7-3.9 3.8-4",
}),
h("path", {
d: "M20.97 5c0 2.1-1.6 3.8-3.5 4",
}),
h("path", { d: "M22 13h-4" }),
h("path", {
d: "M17.2 17c2.1.1 3.8 1.9 3.8 4",
}),
]);
const MAX_SELECTION = 2000;
const MAX_URL_LENGTH = 8000;
const ELLIPSIS = "…";
function buildIssueUrl(selectedText) {
const url = new URL(
"https://github.com/yourname/your-repo/issues/new",
);
const pageUrl = page.value.fullUrl
|| location.href;
const parts = [`**Page:** ${pageUrl}`];
if (selectedText) {
const quoted = selectedText
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
parts.push(`**Selected text:**\n${quoted}`);
}
url.searchParams.set("body", parts.join("\n\n"));
return url;
}
function fileABug() {
let selectedText =
window.getSelection()?.toString().trim()
|| "";
if (selectedText.length > MAX_SELECTION) {
selectedText =
selectedText.slice(0, MAX_SELECTION - ELLIPSIS.length)
+ ELLIPSIS;
}
let url = buildIssueUrl(selectedText);
while (
url.toString().length > MAX_URL_LENGTH
&& selectedText.length > ELLIPSIS.length
) {
const target = Math.max(
ELLIPSIS.length,
Math.floor(selectedText.length * 0.8),
);
selectedText =
selectedText.slice(0, target - ELLIPSIS.length)
+ ELLIPSIS;
url = buildIssueUrl(selectedText);
}
window.open(
url.toString(), "_blank",
"noopener,noreferrer",
);
}
</script>Key design decisions:
- Preserve the selection during the tap — since iOS 15, when a
<button>takes focus on tap, ablurevent fires on the previously focused target and clears the document's text selection; by the timeclickruns,window.getSelection()is empty and the reporter loses their context. Two things prevent that blur from firing:user-select: noneon the button (see the CSS below), andpreventDefault()onmousedownvia the Vue modifier@mousedown.prevent. With both in place, the live selection survives intoclickon desktop and mobile, so readingwindow.getSelection()inside the handler Just Works — no snapshot or pointer-event dance needed. - Multiline blockquote — each line of the selection is prefixed with
>so the entire quote renders correctly in the GitHub issue body. - Truncation in two stages — selections longer than
MAX_SELECTIONare clipped with an ellipsis (the off-by-one fix: slice toMAX_SELECTION - ELLIPSIS.lengthso the cap holds at exactlyMAX_SELECTIONcharacters). ThenbuildIssueUrl()is called and the result re-measured: if the encoded URL exceedsMAX_URL_LENGTH, the selection is shrunk geometrically (~80% per iteration) until the URL fits. This handles the case where blockquote prefixing (>per line) and percent-encoding inflate the body well past the raw selection length. noopener,noreferreronwindow.openprevents the opened tab from accessingwindow.opener(reverse-tabnabbing).page.value.fullUrlcomes from thetransformPageDatahook (see Copy Buttons) and falls back tolocation.hrefif unavailable.- Inline SVG icon via
h()avoids external icon dependencies, matching the pattern used by CopyButtons.
Style the button
Add to .vitepress/theme/custom.css:
css
button {
user-select: none;
-webkit-user-select: none;
}
.file-a-bug-btn {
display: inline-flex;
align-items: center;
gap: 0.3em;
}Apply user-select: none (with the -webkit- prefix for iOS Safari) to every <button> on the site, not just this one. Buttons are not meant to be selectable content, and on iOS the tap-to-focus behavior of a selectable button is exactly what clears the document's text selection. Making it site-wide also keeps future buttons from regressing the same bug.
The .file-a-bug-btn block reuses .inline-copy-btn for base styling (background, border, font size, hover state). The override switches from inline-grid to inline-flex since this button has no idle/success state swap.
Integrate into the layout
If you already have a CustomLayout.vue with a doc-before slot, wrap existing action buttons and the new component in a flex container:
vue
<template>
<Layout>
<template #doc-before>
<div class="doc-action-buttons">
<CopyButtons />
<FileABug />
</div>
</template>
</Layout>
</template>
<script setup>
import DefaultTheme from "vitepress/theme";
import CopyButtons from "./CopyButtons.vue";
import FileABug from "./FileABug.vue";
const { Layout } = DefaultTheme;
</script>Add a flex wrapper for the action buttons row, and make .copy-buttons a nested flex container so its children space themselves with gap:
css
.doc-action-buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
margin-bottom: 0.5rem;
}
.copy-buttons {
display: flex;
gap: 0.5em;
}Checks
- [ ] Button appears on every page alongside copy buttons
- [ ] Clicking opens GitHub issue form in a new tab
- [ ] Issue body contains the current page URL
- [ ] Selecting text then clicking includes the selection as a blockquote
- [ ] On mobile Safari, selecting text then scrolling and tapping the button still captures the selection
- [ ] Button adapts to light and dark themes via VitePress CSS variables
- [ ] Build completes without errors (
npm run docs:build)