Building a Copy to Clipboard Button in JavaScript

A complete tutorial on building a copy-to-clipboard button in JavaScript. Covers navigator.clipboard.writeText, fallback with document.execCommand, visual feedback with success and error states, copying from code blocks and input fields, accessibility with ARIA attributes, tooltip-based feedback, framework-agnostic patterns, and building a reusable CopyButton component.

JavaScriptintermediate
14 min read

A copy-to-clipboard button is one of the most common UI patterns in documentation sites, code playgrounds, and dashboards. This guide walks through building a production-ready implementation with the modern Clipboard API, legacy fallbacks, visual feedback, and accessibility.

For the full Clipboard API reference, see JavaScript Clipboard API: a complete tutorial.

Minimal Implementation

javascriptjavascript
const button = document.getElementById("copy-btn");
const target = document.getElementById("code-block");
 
button.addEventListener("click", async () => {
  try {
    await navigator.clipboard.writeText(target.textContent);
    button.textContent = "Copied!";
    setTimeout(() => (button.textContent = "Copy"), 2000);
  } catch (error) {
    console.error("Copy failed:", error.message);
    button.textContent = "Failed";
    setTimeout(() => (button.textContent = "Copy"), 2000);
  }
});

Fallback for Older Browsers

navigator.clipboard requires HTTPS and a secure context. For HTTP pages or older browsers, fall back to document.execCommand:

javascriptjavascript
function copyToClipboard(text) {
  if (navigator.clipboard && window.isSecureContext) {
    return navigator.clipboard.writeText(text);
  }
 
  // Legacy fallback
  return new Promise((resolve, reject) => {
    const textarea = document.createElement("textarea");
    textarea.value = text;
    textarea.setAttribute("readonly", "");
    textarea.style.cssText =
      "position:fixed;top:-9999px;left:-9999px;opacity:0";
 
    document.body.appendChild(textarea);
    textarea.select();
 
    try {
      const success = document.execCommand("copy");
      document.body.removeChild(textarea);
      success ? resolve() : reject(new Error("execCommand copy failed"));
    } catch (error) {
      document.body.removeChild(textarea);
      reject(error);
    }
  });
}
 
// Usage
await copyToClipboard("Hello, world!");

Copy Button With Visual Feedback

javascriptjavascript
class CopyButton {
  constructor(element, options = {}) {
    this.button = element;
    this.targetSelector = element.dataset.copyTarget;
    this.staticText = element.dataset.copyText;
    this.originalHTML = element.innerHTML;
    this.feedbackDuration = options.feedbackDuration || 2000;
    this.successHTML = options.successHTML || "Copied!";
    this.errorHTML = options.errorHTML || "Failed";
    this.timer = null;
    this.isCopying = false;
 
    this.button.addEventListener("click", () => this.copy());
  }
 
  async copy() {
    if (this.isCopying) return;
    this.isCopying = true;
 
    const text = this.getText();
 
    if (!text) {
      this.showFeedback(this.errorHTML, "error");
      return;
    }
 
    try {
      await copyToClipboard(text);
      this.showFeedback(this.successHTML, "success");
    } catch {
      this.showFeedback(this.errorHTML, "error");
    }
  }
 
  getText() {
    if (this.staticText) return this.staticText;
 
    if (this.targetSelector) {
      const target = document.querySelector(this.targetSelector);
      if (!target) return null;
 
      // For code blocks, get textContent to strip HTML
      if (target.tagName === "CODE" || target.tagName === "PRE") {
        return target.textContent;
      }
 
      // For inputs, get value
      if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
        return target.value;
      }
 
      return target.textContent;
    }
 
    return null;
  }
 
  showFeedback(html, state) {
    clearTimeout(this.timer);
 
    this.button.innerHTML = html;
    this.button.dataset.state = state;
    this.button.setAttribute("aria-label", state === "success" ? "Copied" : "Copy failed");
 
    this.timer = setTimeout(() => {
      this.button.innerHTML = this.originalHTML;
      delete this.button.dataset.state;
      this.button.setAttribute("aria-label", "Copy to clipboard");
      this.isCopying = false;
    }, this.feedbackDuration);
  }
}

HTML Structure

htmlhtml
<div class="code-block-wrapper">
  <pre><code id="example-code">const greeting = "Hello, world!";
console.log(greeting);</code></pre>
  <button
    class="copy-btn"
    data-copy-target="#example-code"
    aria-label="Copy to clipboard"
  >
    Copy
  </button>
</div>

CSS for Copy Button States

javascriptjavascript
// Add this CSS dynamically or in your stylesheet
const styles = `
  .copy-btn {
    position: absolute;
    top: 8px;
    right: 8px;
    padding: 4px 12px;
    border: 1px solid #d1d5db;
    border-radius: 6px;
    background: #f9fafb;
    color: #374151;
    font-size: 13px;
    cursor: pointer;
    transition: all 0.15s ease;
  }
 
  .copy-btn:hover {
    background: #f3f4f6;
    border-color: #9ca3af;
  }
 
  .copy-btn[data-state="success"] {
    background: #dcfce7;
    border-color: #86efac;
    color: #166534;
  }
 
  .copy-btn[data-state="error"] {
    background: #fef2f2;
    border-color: #fca5a5;
    color: #991b1b;
  }
 
  .code-block-wrapper {
    position: relative;
  }
`;
 
const styleEl = document.createElement("style");
styleEl.textContent = styles;
document.head.appendChild(styleEl);

SVG Icon Button With Tooltip

javascriptjavascript
class IconCopyButton {
  constructor(element) {
    this.button = element;
    this.timer = null;
 
    this.copyIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
      <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
      <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
    </svg>`;
 
    this.checkIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
      <polyline points="20 6 9 17 4 12"></polyline>
    </svg>`;
 
    this.button.innerHTML = this.copyIcon;
    this.button.setAttribute("aria-label", "Copy to clipboard");
    this.button.title = "Copy";
 
    this.button.addEventListener("click", () => this.copy());
  }
 
  async copy() {
    const target = document.querySelector(this.button.dataset.copyTarget);
    if (!target) return;
 
    try {
      await copyToClipboard(target.textContent);
      this.showSuccess();
    } catch {
      this.button.title = "Copy failed";
      setTimeout(() => (this.button.title = "Copy"), 2000);
    }
  }
 
  showSuccess() {
    clearTimeout(this.timer);
    this.button.innerHTML = this.checkIcon;
    this.button.title = "Copied!";
    this.button.classList.add("copied");
 
    this.timer = setTimeout(() => {
      this.button.innerHTML = this.copyIcon;
      this.button.title = "Copy";
      this.button.classList.remove("copied");
    }, 2000);
  }
}

Auto-Initializing All Code Blocks

javascriptjavascript
function initCopyButtons(options = {}) {
  const selector = options.codeSelector || "pre > code";
  const codeBlocks = document.querySelectorAll(selector);
 
  codeBlocks.forEach((code, index) => {
    const pre = code.closest("pre");
    if (!pre) return;
 
    // Skip if already has a copy button
    if (pre.parentElement.querySelector(".copy-btn")) return;
 
    // Wrap in a relative container
    const wrapper = document.createElement("div");
    wrapper.className = "code-block-wrapper";
    pre.parentNode.insertBefore(wrapper, pre);
    wrapper.appendChild(pre);
 
    // Create copy button
    const button = document.createElement("button");
    button.className = "copy-btn";
    button.setAttribute("aria-label", "Copy to clipboard");
    code.id = code.id || `code-block-${index}`;
    button.dataset.copyTarget = `#${code.id}`;
    wrapper.appendChild(button);
 
    new IconCopyButton(button);
  });
}
 
// Initialize on page load
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", () => initCopyButtons());
} else {
  initCopyButtons();
}

Accessibility Considerations

RequirementImplementation
Keyboard accessibleUse <button> element (focusable by default)
Screen reader labelaria-label="Copy to clipboard"
Status announcementaria-live="polite" region for feedback
Visual focus indicatorCSS :focus-visible outline
State communicationUpdate aria-label on success/error
javascriptjavascript
// Accessible announcement
function announceToScreenReader(message) {
  let region = document.getElementById("copy-announcer");
 
  if (!region) {
    region = document.createElement("div");
    region.id = "copy-announcer";
    region.setAttribute("aria-live", "polite");
    region.setAttribute("aria-atomic", "true");
    region.className = "sr-only"; // visually hidden
    document.body.appendChild(region);
  }
 
  region.textContent = "";
  // Small delay to trigger announcement on repeated copies
  requestAnimationFrame(() => {
    region.textContent = message;
  });
}
 
// In your copy handler:
try {
  await copyToClipboard(text);
  announceToScreenReader("Code copied to clipboard");
} catch {
  announceToScreenReader("Failed to copy code");
}

Copying Different Content Types

SourceExtraction MethodNotes
<pre><code>element.textContentStrips syntax highlighting spans
<input>element.valueWorks for text, email, URL inputs
<textarea>element.valuePreserves newlines
<td> / <th>element.textContentSingle cell content
<table>Custom formatterTab-separated for spreadsheet paste
<div contenteditable>element.innerTextPreserves visual line breaks
javascriptjavascript
// Copy an entire table as tab-separated values
function copyTable(tableElement) {
  const rows = tableElement.querySelectorAll("tr");
  const lines = [];
 
  rows.forEach((row) => {
    const cells = row.querySelectorAll("td, th");
    const values = [...cells].map((c) => c.textContent.trim());
    lines.push(values.join("\t"));
  });
 
  return copyToClipboard(lines.join("\n"));
}
Rune AI

Rune AI

Key Insights

  • Always provide a fallback: The Clipboard API requires HTTPS; use document.execCommand('copy') with a hidden textarea for HTTP and older browsers
  • Visual feedback is essential: Users need confirmation that the copy succeeded; swap the button text or icon for 2 seconds then reset
  • Use textContent for code blocks: textContent strips HTML tags from syntax-highlighted code, giving users clean source code
  • Accessibility matters: Use semantic <button> elements, announce copy results with aria-live regions, and update aria-label on state changes
  • Auto-initialize on all code blocks: Wrap each <pre> in a relative container and inject copy buttons programmatically for consistent documentation UX
RunePowered by Rune AI

Frequently Asked Questions

Why does copy fail on HTTP pages?

The Clipboard API requires a secure context (HTTPS or localhost). On HTTP pages, `navigator.clipboard` is undefined. Always include the `document.execCommand` fallback for HTTP environments or use a bundled library that handles this automatically.

Can I copy without a user click event?

No. Browsers require a user gesture (click, keypress) to trigger clipboard writes. Calling `writeText` from a timer, `fetch` callback, or page load script will throw a `NotAllowedError`. Always trigger copy from a direct event handler.

How do I copy formatted code with syntax highlighting?

Use `navigator.clipboard.write()` with both `text/html` and `text/plain` MIME types. The HTML version preserves colors and formatting when pasted into rich text editors, while the plain text version is used by code editors. See [JavaScript Clipboard API: a complete tutorial](/tutorials/programming-languages/javascript/javascript-clipboard-api-a-complete-tutorial) for rich content copying.

Should I use a library or build my own?

For a single copy button, the code in this guide is sufficient. For complex needs (rich content, cross-browser edge cases, React/Vue integration), consider lightweight libraries. The core logic is small enough that a custom implementation avoids unnecessary dependencies.

How do I test copy-to-clipboard in automated tests?

Mock `navigator.clipboard.writeText` in your test setup. For Playwright/Cypress, grant clipboard permissions in the browser context. For unit tests, spy on the clipboard method and assert it was called with the correct text. The `document.execCommand` fallback can be tested similarly.

Conclusion

A well-built copy button needs the modern Clipboard API, an execCommand fallback, clear visual feedback, and proper accessibility. Auto-initialize buttons on all code blocks for documentation sites, use icon buttons with tooltips for compact UIs, and always announce state changes to screen readers. For the underlying Clipboard API details, see JavaScript Clipboard API: a complete tutorial. For storing copied content, see JS localStorage API guide.