How to add a copy button to code blocks

Add a small Copy button to every code block on your page that copies the code, says Copied for a moment, and tells screen reader users what happened.

Illustration of a code box with a Copy button switching to Copied

You know that little Copy button in the corner of every code box on this blog? In this tutorial you'll build the same thing: a header bar on top of a code block, with a button that copies the code, switches to Copied for a moment, and then goes back to Copy. You'll also make it friendly for keyboard and screen reader users, give it a backup plan for when the clipboard says no, and wire up every code block on a page with one small script.

Start with the markup

Each code block is a wrapper with two parts: a small bar on top with a label and a button, and a pre with a code inside it underneath. The pre keeps your spaces and line breaks exactly as you typed them.

Use a real button element with type="button". You get keyboard focus, Enter and Space support, and the right role for screen readers for free. Add one visually hidden status line anywhere on the page too. We'll use it later to announce the copy.

Notice the angle brackets inside the code are written as < and >. That way the browser shows the tag as text instead of trying to render it.

HTML
<div class="code-box">
  <div class="code-bar">
    <span class="code-label">HTML</span>
    <button type="button" class="copy-btn">Copy</button>
  </div>
  <pre><code>&lt;p class="hello"&gt;Hi there!&lt;/p&gt;</code></pre>
</div>

<!-- One shared status line for screen readers, anywhere on the page -->
<p id="copy-status" class="visually-hidden" aria-live="polite"></p>

Style the bar and the button

This gives you a dark editor look: a slightly lighter bar on top, a soft grey button, and a code area that scrolls sideways when a line is long, instead of breaking your layout.

Two small details matter here. The button has a min-width, so it doesn't jump in size when Copy turns into Copied. And it has a clear focus-visible outline, so keyboard users can see where they are. The hover fade is switched off for anyone who has asked their system for reduced motion.

CSS
.code-box {
  margin: 22px 0;
  border: 1px solid #1e1e1e;
  border-radius: 12px;
  overflow: hidden;
  background: #1e1e1e;
}

.code-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 40px;
  padding: 0 8px 0 14px;
  background: #252526;
  border-bottom: 1px solid #333333;
  color: #cccccc;
  font-family: ui-monospace, Menlo, Consolas, monospace;
  font-size: 12px;
}

.copy-btn {
  font: inherit;
  min-width: 64px; /* "Copy" and "Copied" take up the same space */
  height: 28px;
  padding: 0 10px;
  border: 1px solid #4a4a4a;
  border-radius: 6px;
  background: #3c3c3c;
  color: #cccccc;
  cursor: pointer;
  transition: background-color 0.15s ease;
}

.copy-btn:hover {
  background: #4a4a4a;
}

.copy-btn:focus-visible {
  outline: 2px solid #9cdcfe;
  outline-offset: 2px;
}

.code-box pre {
  margin: 0;
  padding: 16px;
  overflow-x: auto;
  color: #d4d4d4;
  font-family: ui-monospace, Menlo, Consolas, monospace;
  font-size: 13px;
  line-height: 1.6;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

@media (prefers-reduced-motion: reduce) {
  .copy-btn {
    transition: none;
  }
}

Copy the text with the Clipboard API

The modern way to copy is navigator.clipboard.writeText(). You pass it a string and it returns a promise, so you can await it and know for sure whether the copy worked.

It has a couple of rules. It only works in a secure context, which means your page has to be on HTTPS (localhost counts too, so it works while you build). And the browser expects it to happen because the user did something, like clicking. Calling it from a click handler checks both boxes.

This API works in all current major browsers. MDN has a good page on it if you want the full details.

TSX
const button = document.querySelector('.copy-btn');
const code = button.closest('.code-box').querySelector('code');

button.addEventListener('click', async () => {
  try {
    await navigator.clipboard.writeText(code.textContent);
    console.log('Copied!');
  } catch (error) {
    console.log('Copy failed:', error);
  }
});

Show Copied, then switch back

A copy happens silently, so the button needs to tell people it worked. After the promise resolves, change the text to Copied and use setTimeout to change it back about 1.6 seconds later.

Watch out for double clicks. If someone clicks twice, you'd start two timers, and the first one would flip the button back to Copy too early. The fix is to save the timer and call clearTimeout before starting a new one. Now every click simply restarts the countdown.

TSX
const button = document.querySelector('.copy-btn');
const code = button.closest('.code-box').querySelector('code');
let resetTimer;

function showCopied() {
  button.textContent = 'Copied';
  clearTimeout(resetTimer); // a second click restarts the timer
  resetTimer = setTimeout(() => {
    button.textContent = 'Copy';
  }, 1600);
}

button.addEventListener('click', async () => {
  try {
    await navigator.clipboard.writeText(code.textContent);
    showCopied();
  } catch (error) {
    button.textContent = 'Copy';
  }
});

Tell screen readers it worked

Changing the button text is great for sighted users, but a screen reader might not read it out after the click. That's what the hidden status line with aria-live="polite" is for. When its text changes, screen readers announce it once they finish what they're saying.

Clear the text first, then set it after a short pause. If you set the exact same message twice in a row, some screen readers treat it as no change and stay quiet.

TSX
const status = document.getElementById('copy-status');

function announce(message) {
  status.textContent = '';
  // A tiny pause makes screen readers treat it as new text,
  // even when it's the same message twice in a row.
  setTimeout(() => {
    status.textContent = message;
  }, 50);
}

// Later, after a copy works:
// announce('Code copied to clipboard');

Add a fallback and wire up every block

Sometimes the Clipboard API isn't there (an old browser, or a page on plain HTTP) or it gets blocked. As a backup, select the code on the page and try document.execCommand('copy'). It's marked as deprecated, so treat it as a legacy safety net, not your main plan. If even that fails, the code stays selected, and the reader can press Ctrl+C or Cmd+C themselves.

To handle many code blocks with one script, listen for clicks on the whole document and use closest('.copy-btn') to find which button was pressed. From there, closest('.code-box') finds its own code. A WeakMap keeps one timer per button, so blocks never mess with each other's text. This also works for code blocks you add to the page later.

Here's the full script. Put it at the end of your page or in a file loaded with defer, and every code box gets a working Copy button.

TSX
const status = document.getElementById('copy-status');
const resetTimers = new WeakMap();

function announce(message) {
  status.textContent = '';
  setTimeout(() => {
    status.textContent = message;
  }, 50);
}

function fallbackCopy(codeEl) {
  const range = document.createRange();
  range.selectNodeContents(codeEl);
  const selection = window.getSelection();
  selection.removeAllRanges();
  selection.addRange(range);

  let copied = false;
  try {
    copied = document.execCommand('copy');
  } catch (error) {
    copied = false;
  }
  if (copied) {
    selection.removeAllRanges();
  }
  return copied;
}

function showCopied(button) {
  button.textContent = 'Copied';
  clearTimeout(resetTimers.get(button));
  resetTimers.set(button, setTimeout(() => {
    button.textContent = 'Copy';
  }, 1600));
}

document.addEventListener('click', async (event) => {
  const button = event.target.closest('.copy-btn');
  if (!button) return;

  const codeEl = button.closest('.code-box').querySelector('code');
  let copied = false;

  if (navigator.clipboard && window.isSecureContext) {
    try {
      await navigator.clipboard.writeText(codeEl.textContent);
      copied = true;
    } catch (error) {
      copied = fallbackCopy(codeEl);
    }
  } else {
    copied = fallbackCopy(codeEl);
  }

  if (copied) {
    showCopied(button);
    announce('Code copied to clipboard');
  } else {
    announce('Copy did not work. The code is selected, press Control or Command plus C to copy it.');
  }
});

One last tip: always copy with textContent, not innerHTML. Your code block stores a tag as &lt;p&gt; so the browser shows it as text, and textContent hands the reader a clean <p> they can paste straight into their editor.