feat(docs): add copy-to-clipboard button on code blocks
Add a Svelte action that wraps pre blocks in a container and injects a fixed copy button. Includes checkmark feedback, pre code style reset, wrapper border-radius handling, and compact padding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yechan Kim committed
Apr 12, 2026 at 19:44 UTC
ce2f6858d9cdca18df0c80fcddfccc06584e662f
3 files changed
+105
-1
docs/src/app.css
+28
@@ -158,14 +158,42 @@ h1, h2, h3, h4, h5, h6 {
158
border-color: oklch(75% 0.14 195 / 0.2);
159
}
160
161
+/* Reset inline code styles inside code blocks */
162
+.prose :where(pre code):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
163
+ background-color: transparent;
164
+ padding: 0;
165
+ border-radius: 0;
166
+ border: none;
167
+ font-size: inherit;
168
+}
169
+
170
/* Code blocks — relay terminal aesthetic */
171
.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
172
background-color: oklch(20% 0.005 250);
173
border-radius: 0.75rem;
174
overflow-x: auto;
175
+ padding: 0.75rem 1rem;
176
border: 1px solid oklch(100% 0 0 / 0.05);
177
}
178
179
+/* When wrapped by copy-code action, move radius/border to wrapper */
180
+[data-copy-wrapper] {
181
+ border-radius: 0.75rem;
182
+ overflow: hidden;
183
+ border: 1px solid oklch(100% 0 0 / 0.05);
184
+ background-color: oklch(20% 0.005 250);
185
+}
186
+
187
+.dark [data-copy-wrapper] {
188
+ border-color: oklch(100% 0 0 / 0.1);
189
+ background-color: oklch(14% 0.008 250);
190
+}
191
+
192
+[data-copy-wrapper] pre {
193
+ border-radius: 0 !important;
194
+ border: none !important;
195
+}
196
+
197
.dark .prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
198
background-color: oklch(14% 0.008 250);
199
border-color: oklch(100% 0 0 / 0.1);
docs/src/lib/actions/copy-code.ts
new
+75
@@ -0,0 +1,75 @@
1
+/**
2
+ * Svelte action that adds a copy button to all <pre> blocks inside an element.
3
+ * Wraps each <pre> in a container div so the button stays fixed even when
4
+ * the code scrolls horizontally.
5
+ * Usage: <article use:copyCode={pathname}>
6
+ */
7
+export function copyCode(node: HTMLElement) {
8
+ const wrappers: HTMLDivElement[] = [];
9
+
10
+ function addButtons() {
11
+ removeButtons();
12
+
13
+ const pres = node.querySelectorAll('pre');
14
+ for (const pre of pres) {
15
+ // Skip if already wrapped
16
+ if (pre.parentElement?.hasAttribute('data-copy-wrapper')) continue;
17
+
18
+ // Wrap pre in a relative container
19
+ const wrapper = document.createElement('div');
20
+ wrapper.setAttribute('data-copy-wrapper', '');
21
+ wrapper.style.position = 'relative';
22
+ pre.parentNode?.insertBefore(wrapper, pre);
23
+ wrapper.appendChild(pre);
24
+
25
+ const btn = document.createElement('button');
26
+ btn.setAttribute('data-copy-btn', '');
27
+ btn.setAttribute('aria-label', 'Copy code');
28
+ btn.setAttribute('title', 'Copy');
29
+ btn.className =
30
+ 'absolute top-2.5 right-2.5 z-10 inline-flex h-7 w-7 items-center justify-center rounded-md text-gray-400 transition-colors hover:text-gray-200';
31
+ btn.innerHTML = `<svg class="h-[22px] w-[22px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>`;
32
+
33
+ btn.addEventListener('click', async () => {
34
+ const code = pre.querySelector('code');
35
+ const text = code?.textContent ?? pre.textContent ?? '';
36
+ try {
37
+ await navigator.clipboard.writeText(text);
38
+ btn.innerHTML = `<svg class="h-[22px] w-[22px] text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`;
39
+ btn.setAttribute('title', 'Copied!');
40
+ setTimeout(() => {
41
+ btn.innerHTML = `<svg class="h-[22px] w-[22px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>`;
42
+ btn.setAttribute('title', 'Copy');
43
+ }, 2000);
44
+ } catch {
45
+ // Fallback: silent fail
46
+ }
47
+ });
48
+
49
+ wrapper.appendChild(btn);
50
+ wrappers.push(wrapper);
51
+ }
52
+ }
53
+
54
+ function removeButtons() {
55
+ for (const wrapper of wrappers) {
56
+ const pre = wrapper.querySelector('pre');
57
+ if (pre && wrapper.parentNode) {
58
+ wrapper.parentNode.insertBefore(pre, wrapper);
59
+ wrapper.remove();
60
+ }
61
+ }
62
+ wrappers.length = 0;
63
+ }
64
+
65
+ requestAnimationFrame(addButtons);
66
+
67
+ return {
68
+ update() {
69
+ requestAnimationFrame(addButtons);
70
+ },
71
+ destroy() {
72
+ removeButtons();
73
+ }
74
+ };
75
+}
docs/src/routes/+layout.svelte
+2
-1
@@ -8,6 +8,7 @@
8
import TableOfContents from '$lib/components/TableOfContents.svelte';
9
import PrevNextNav from '$lib/components/PrevNextNav.svelte';
10
import { getPrevNext } from '$lib/nav';
11
+ import { copyCode } from '$lib/actions/copy-code';
12
import '../app.css';
13
14
let { children } = $props();
@@ -136,7 +137,7 @@
137
</aside>
138
139
<main class="min-w-0 flex-1 px-4 py-8 lg:px-8">
139
- <article class="prose prose-gray dark:prose-invert mx-auto max-w-3xl">
140
+ <article use:copyCode={$page.url.pathname} class="prose prose-gray dark:prose-invert mx-auto max-w-3xl">
141
{@render children()}
142
</article>
143
<div class="mx-auto max-w-3xl">