komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Content & MDX••By Komal Nakrani

Astro Copy Code Button for Every Code Block

Add an Astro copy code button to every Markdown code block with one small script: no plugin, keyboard accessible, with a live status message for screen readers.

#Astro#Components#Code Blocks#Markdown

The simplest Astro copy code button needs no remark plugin: a short script in your post layout finds every <pre> that Shiki renders, adds a button to each, and copies the code’s text with the Clipboard API. It reports “Copied” through a live region so screen-reader users hear it too. Put it in the layout once and every Markdown and MDX code block gets a button.

Key Takeaways

  • Astro’s Shiki output is plain <pre><code>, so a script can enhance it after load.
  • navigator.clipboard.writeText() works on HTTPS and localhost.
  • Use a real <button> and an aria-live region for feedback.
  • Wrap the <pre> in a positioned container so the button sits in the corner.

The script

Add this to the layout that renders your posts:

---
// src/layouts/BlogPost.astro (excerpt)
---
<article class="prose">
  <slot />
</article>
<p class="sr-only" aria-live="polite" id="copy-status"></p>

<script>
  function addCopyButtons() {
    const status = document.getElementById('copy-status');
    document.querySelectorAll<HTMLPreElement>('.prose pre').forEach((pre) => {
      if (pre.parentElement?.classList.contains('code-wrap')) return;

      const wrap = document.createElement('div');
      wrap.className = 'code-wrap';
      pre.replaceWith(wrap);
      wrap.append(pre);

      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'copy-btn';
      button.textContent = 'Copy';
      button.setAttribute('aria-label', 'Copy code to clipboard');
      wrap.append(button);

      button.addEventListener('click', async () => {
        const text = pre.querySelector('code')?.innerText ?? pre.innerText;
        try {
          await navigator.clipboard.writeText(text);
          button.textContent = 'Copied';
          if (status) status.textContent = 'Code copied to clipboard';
        } catch {
          button.textContent = 'Press Ctrl+C';
        }
        setTimeout(() => (button.textContent = 'Copy'), 2000);
      });
    });
  }

  addCopyButtons();
  document.addEventListener('astro:page-load', addCopyButtons);
</script>

<style is:global>
  .code-wrap { position: relative; }
  .copy-btn {
    position: absolute; top: 0.5rem; right: 0.5rem;
    padding: 0.25rem 0.6rem; font-size: 0.75rem; border-radius: 0.35rem;
    border: 1px solid rgb(255 255 255 / 0.25); background: rgb(255 255 255 / 0.08);
    color: inherit; cursor: pointer; opacity: 0; transition: opacity 0.15s;
  }
  .code-wrap:hover .copy-btn, .copy-btn:focus-visible { opacity: 1; }
  @media (hover: none) { .copy-btn { opacity: 1; } }
  .sr-only {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
  }
</style>

is:global is needed because the <pre> elements come from Markdown, outside this component’s scoped template.

Why innerText

innerText returns the code as displayed, without Shiki’s syntax-highlighting <span> markup. If you use line numbers implemented as text, strip them before copying.

Showing the language

Shiki adds data-language to <pre>. Add a label in the same loop:

const lang = pre.dataset.language;
if (lang) {
  const label = document.createElement('span');
  label.className = 'code-lang';
  label.textContent = lang;
  wrap.append(label);
}

Touch devices

The @media (hover: none) rule keeps the button visible on phones and tablets, where there’s no hover to reveal it.

Alternatives

  • Expressive Code (used by Starlight) adds copy buttons, frames and titles as an integration, if you want more than a button.
  • A rehype plugin can add the button at build time, which avoids the DOM work but makes the markup more complex.

For a small blog, the script above is usually enough. Blog layouts with code styling already in place are among the blog and editorial themes. Code block styling is covered in the Astro styling guide, and Markdown rendering in the Astro content collections guide. MDN’s Clipboard API page covers browser support.

FAQ

Why doesn’t copying work on my staging site?

The Clipboard API requires a secure context: HTTPS or localhost. Plain HTTP previews fail and show the fallback text.

Can I add this to MDX code blocks too?

Yes. MDX code blocks render to the same <pre> markup, so the script handles both.