Build a Custom Code Snippet Component in Astro (Copy Button Included)
Shiki highlighting at build time, one-click copy, and fixing the 'Cannot use set:html with user input' scare.
Tested on: Astro 5.7, Shiki 1.29
Goal: Replace <pre> blocks with highlighted, copyable snippets. Zero runtime highlighter.
Enable Shiki in astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
markdown: {
shikiConfig: {
theme: 'github-dark',
wrap: true
}
}
});
MDX/Markdown code fences auto-highlight. For manual use, build a component.
CodeSnippet.astro
---
import { codeToHtml } from 'shiki';
interface Props {
code: string;
lang?: string;
filename?: string;
}
const { code, lang = 'bash', filename } = Astro.props;
const html = await codeToHtml(code, { lang, theme: 'github-dark' });
const id = `snippet-${Math.random().toString(36).slice(2, 9)}`;
---
<figure class="code-snippet" data-snippet-id={id}>
{filename && <figcaption class="filename">{filename}</figcaption>}
<div class="code-body" set:html={html} />
<button class="copy-btn" data-target={id}>Copy</button>
</figure>
<script>
document.querySelectorAll('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const fig = btn.closest('.code-snippet');
const code = fig?.querySelector('code')?.textContent ?? '';
await navigator.clipboard.writeText(code);
btn.textContent = 'Copied!';
setTimeout(() => (btn.textContent = 'Copy'), 2000);
});
});
</script>
<style>
.code-snippet { position: relative; margin: 1.5rem 0; }
.copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; }
.filename { font-family: monospace; font-size: 0.85rem; padding: 0.25rem 0.75rem; background: #1e1e1e; color: #aaa; }
</style>
Usage in any page
---
import CodeSnippet from '../components/CodeSnippet.astro';
const installCmd = 'npm install astro';
---
<CodeSnippet code={installCmd} lang="bash" filename="terminal" />
Build error — top-level await in component
Top-level await is not available in client components
Shiki runs in frontmatter (server/build time). The <script> tag is vanilla JS — no Shiki import there. Keep them separate.
Security note
Only pass trusted code strings (your tutorials). Never pass user input to set:html.
CSS clash with global styles
pre { background: white !important; } /* killed Shiki theme */
Scope styles:
.code-snippet :global(pre) { background: transparent !important; }
Output
Highlighting: build time (0ms client cost)
Copy script: ~400 bytes inlined per page with snippets
No highlight.js in browser