Blog / Coding tips

JavaScript Copy to Clipboard Button (Tested Code)

To make a copy to clipboard button in JavaScript, listen for the button's click event and call await navigator.clipboard.writeText(text) inside an async handler, wrapped in try/catch. That's the modern Clipboard API, and it works in every current browser, with one big condition: the page must be a secure context, which means HTTPS or localhost. On a plain http:// address, navigator.clipboard is undefined, so you need a small fallback. Below is a complete, tested button that copies from a <div>, <span> or <input>, shows "Copied!" for two seconds, and falls back on old-style copying when the modern API isn't available.

I wanted copy buttons in two places on my own site: next to the code examples in these tips, and next to share links on my projects page. Every snippet below was run in headless Chrome 154 with Puppeteer. After each click, my test script read the real clipboard back, so the outputs you see are what actually got copied, not what I hoped would be copied.

The copy to clipboard button: HTML and JavaScript

Here's the HTML. Each button has a data-copy-target attribute holding a CSS selector for the element it should copy. That way one script can handle as many buttons as you like:

<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <title>Copy to clipboard</title>
</head>
<body>
  <p>Your referral code: <span id="code">MAHIR-2026</span></p>
  <button type="button" data-copy-target="#code">Copy code</button>

  <label for="share">Share link</label>
  <input id="share" value="https://mfaysal.com/projects" readonly>
  <button type="button" data-copy-target="#share">Copy link</button>

  <script src="copy.js"></script>
</body>
</html>

And here's copy.js, the whole JavaScript copy to clipboard function plus the click handlers:

// copy.js: copy to clipboard buttons with feedback and an HTTP fallback
async function copyText(text) {
  if (navigator.clipboard && window.isSecureContext) {
    await navigator.clipboard.writeText(text);   // modern API: HTTPS or localhost
    return;
  }
  // Fallback for plain http:// pages and very old browsers
  const textarea = document.createElement("textarea");
  textarea.value = text;
  textarea.setAttribute("readonly", "");
  textarea.style.position = "fixed";
  textarea.style.opacity = "0";
  document.body.appendChild(textarea);
  textarea.select();
  const ok = document.execCommand("copy");
  textarea.remove();
  if (!ok) throw new Error("Copy command was rejected");
}

document.querySelectorAll("[data-copy-target]").forEach((button) => {
  button.addEventListener("click", async () => {
    const source = document.querySelector(button.dataset.copyTarget);
    const text = "value" in source ? source.value : source.textContent;
    const label = button.textContent;
    try {
      await copyText(text.trim());
      button.textContent = "Copied!";
    } catch (err) {
      button.textContent = "Press Ctrl+C to copy";
      console.error("Copy failed:", err);
    }
    setTimeout(() => (button.textContent = label), 2000);
  });
});

There are two parts, and it's worth understanding both.

The copyText() function

copyText() takes a string and puts it on the clipboard. If navigator.clipboard exists and the page is a secure context, it calls navigator.clipboard.writeText(text). That returns a Promise, which is why the function is async and uses await. If the browser refuses (more on why in a moment), the Promise rejects and the error bubbles up to whoever called copyText().

If the modern API isn't there, it uses the older trick: create a hidden <textarea>, put the text in it, select it, and run document.execCommand("copy"). That command copies whatever is selected on the page. I use a textarea rather than an input because it keeps line breaks, and I make it readonly so phones don't pop up the on-screen keyboard. position: fixed stops the page jumping to the bottom when the element is added.

The click handler

The second part finds every button with data-copy-target, looks up the element it points to, and reads its text:

  • For an <input> or <textarea>, the text lives in .value. The check "value" in source is true for form fields.
  • For a <div>, <span>, <p> or <code>, the text lives in .textContent.

That one line is how you copy to clipboard from a div and copy text from an input field with the same code. After copying, the button label changes to "Copied!" and a setTimeout() puts the original label back after two seconds. If copying fails, the user sees "Press Ctrl+C to copy" instead of nothing at all, which matters more than it sounds: a button that silently does nothing makes people click it five times and give up.

If async and await are new to you, my JavaScript async/await tutorial explains what's happening when the handler pauses on await copyText(...).

Testing it on localhost and on plain HTTP

I served the page from a tiny Node server and opened it twice: once as http://localhost:5173, which browsers treat as secure, and once as http://myapp.test:5173, an ordinary http:// hostname that isn't. Here's the localhost run:

# localhost
isSecureContext: true
button: Copied!
clipboard: "MAHIR-2026"
clipboard: "https://mfaysal.com/projects"
button after 2s: Copy code

Both buttons worked. The <span> gave MAHIR-2026 and the <input> gave the full URL, so the "value" in source check picked the right property each time. Two seconds later the label was back to "Copy code".

Now the same page over plain HTTP:

# http://myapp.test
isSecureContext: false
navigator.clipboard: undefined
direct call: TypeError: Cannot read properties of undefined (reading 'writeText')
button (fallback): Copied!
clipboard: "MAHIR-2026"

This is the result that confuses people. On an insecure page, navigator.clipboard isn't just blocked, it doesn't exist. So code that calls navigator.clipboard.writeText() directly crashes with TypeError: Cannot read properties of undefined (reading 'writeText'). My button still showed "Copied!" and the clipboard really did change back to MAHIR-2026, because copyText() noticed the missing API and used the execCommand fallback.

Why navigator.clipboard is undefined without HTTPS

Reading and writing the clipboard is powerful. A page that can write to your clipboard could swap a bank account number you're about to paste, so browsers only expose the Clipboard API to pages that were delivered securely. The rule is simple:

  • https:// pages: allowed.
  • http://localhost and http://127.0.0.1: allowed, so local development works.
  • http:// on any other hostname, including your local network IP like http://192.168.1.20:3000 when testing on your phone: not allowed.

You can check with window.isSecureContext in the console. If you're testing on a phone over Wi-Fi and the button fails, this is almost always why. The fix in production is HTTPS, which hosts like Vercel and Netlify give you free. The fallback is only there so the button doesn't break in the meantime.

Is document.execCommand("copy") safe to use?

It's marked as deprecated on MDN, which means you shouldn't build new features on it, and browsers could remove it one day. But every major browser still supports it for copying, and it's the only option on an insecure page. Using it as a fallback, as above, is a reasonable compromise: modern browsers on HTTPS never touch it, and if it ever disappears, the catch block still shows a helpful message.

Add a copy button to every code block

On a blog or documentation page, you don't want to write a button by hand for every example. This script finds every <pre> element and adds a Copy button to it automatically:

// codeblocks.js: add a "Copy" button to every <pre> code block
document.querySelectorAll("pre").forEach((pre) => {
  const button = document.createElement("button");
  button.type = "button";
  button.className = "copy-code";
  button.textContent = "Copy";
  pre.append(button);

  button.addEventListener("click", async () => {
    const code = pre.querySelector("code")?.innerText ?? "";
    try {
      await navigator.clipboard.writeText(code);
      button.textContent = "Copied!";
    } catch {
      button.textContent = "Failed";
    }
    setTimeout(() => (button.textContent = "Copy"), 2000);
  });
});

The test page had two code blocks, the second one with two lines. I clicked the second block's button:

# code blocks
buttons added: 2
clipboard: "const total = 3 + 4;\nconsole.log(total);"

Two buttons were added, and the clipboard got exactly the code with the line break preserved, but not the word "Copy". That's why the script reads pre.querySelector("code").innerText instead of pre.textContent: the button is inside the <pre>, so pre.textContent would include the button's own label at the end of every copied snippet. I use innerText here because it respects line breaks the way the code is displayed.

A small CSS rule positions the button in the top-right corner of each block:

pre { position: relative; }
.copy-code { position: absolute; top: .5rem; right: .5rem; }

If you're not sure why the <pre> needs position: relative, my post on CSS position absolute vs relative explains how an absolute element picks the box it's positioned against.

Copy to clipboard with formatting (HTML)

writeText() only copies plain text. If you paste into Word, Google Docs or Gmail, you might want bold text and links to survive. For that, use navigator.clipboard.write() with a ClipboardItem that holds two versions of the same content:

// rich.js: copy formatted HTML with a plain-text version for other apps
async function copyRich(html, plain) {
  const item = new ClipboardItem({
    "text/html": new Blob([html], { type: "text/html" }),
    "text/plain": new Blob([plain], { type: "text/plain" }),
  });
  await navigator.clipboard.write([item]);
}

document.querySelector("#copy-rich").addEventListener("click", () =>
  copyRich(
    '<p>Mark: <strong>72%</strong> (<em>First</em>)</p>',
    "Mark: 72% (First)"
  )
);

After clicking, I read the clipboard back with navigator.clipboard.read():

# rich copy
{
  types: [ 'text/plain', 'text/html' ],
  'text/plain': 'Mark: 72% (First)',
  'text/html': '<p>Mark: <strong>72%</strong> (<em>First</em>)</p>'
}

The clipboard now holds two formats. Apps that understand HTML, like Google Docs, paste the bold and italic version. Apps that don't, like Notepad or a terminal, paste the plain text. Always include text/plain: if you only provide HTML, pasting into a plain text field can give you nothing at all.

Copying the result of a fetch() request

There's one situation where even correct code fails, mainly in Safari: you click a button, the code waits for a fetch() to finish, and then tries to copy. By the time the data arrives, the browser no longer counts it as a response to your click, so it refuses. The documented workaround is to create the ClipboardItem straight away inside the click handler and pass it a Promise that resolves to the Blob once the data arrives:

button.addEventListener("click", () => {
  const text = fetch("/api/share-link")
    .then((res) => res.text())
    .then((link) => new Blob([link], { type: "text/plain" }));
  navigator.clipboard.write([new ClipboardItem({ "text/plain": text })]);
});

I don't have a Mac to test Safari, so treat that part as the approach WebKit recommends rather than something I've confirmed myself. In headless Chrome, with the test server waiting 500 ms before replying, it copied the fetched link correctly:

clipboard after fetch: "https://mfaysal.com/projects/ai-question-generator"

Copy to clipboard from the browser console

If you just want to copy something while debugging, you don't need any of the above. Chrome, Edge and Firefox DevTools have a built-in console helper called copy(). Type copy(document.title) or copy(JSON.stringify(data, null, 2)) in the console and the value goes straight to your clipboard. It only exists in DevTools, not in your page's scripts.

Calling navigator.clipboard.writeText() yourself from the console often fails with "Document is not focused", because focus is on the DevTools panel rather than the page. Click the page first, or use copy().

Common copy to clipboard mistakes

These are the problems I hit or saw most often while building and testing this:

  1. Not awaiting the Promise. Writing navigator.clipboard.writeText(text); button.textContent = "Copied!" shows "Copied!" even when copying failed. Always await it inside try/catch.
  2. Copying without a click. Browsers expect clipboard writes to come from a user action like a click or key press. Copying on page load, or in a timer long after a click, is blocked in Firefox and Safari. Keep the copy inside the click handler.
  3. Testing over your local network on plain HTTP. As shown above, navigator.clipboard is undefined there. Use localhost, HTTPS, or the fallback.
  4. Copying the button's own label. If the button sits inside the element you copy, read a child element or use a separate target, as in the code block example.
  5. Inside an iframe. Embedded pages, such as a CodePen or a widget, need the parent page to allow it: <iframe src="..." allow="clipboard-write">. Without that, writeText() rejects with a NotAllowedError.
  6. No feedback. A copy button with no visual change feels broken. Change the label, show a tick, or use a small toast. For screen reader users, an element with aria-live="polite" that says "Copied" is a nice extra.
  7. Calling it on every keystroke. If you're copying something that updates as the user types, copy on a button press, not on every input event. If you really need to react to typing, use a debounce function so it only fires once the user pauses.

The same "store something for the user" idea shows up in my dark mode toggle with localStorage, where the try/catch matters for the same reason: some browsers block the feature completely, and the page should still work.

FAQ

How do I copy text to the clipboard in JavaScript on click?

Add a click listener to a button, and inside an async handler call await navigator.clipboard.writeText("your text") in a try/catch. The page must be on HTTPS or localhost. The full button code is at the top of this post.

Why is navigator.clipboard undefined?

The page isn't a secure context. The Clipboard API is only available on https:// pages and on localhost. On plain http://, including your local IP address when testing on a phone, it's undefined. Switch to HTTPS or use the execCommand("copy") fallback.

How do I copy the text of a div to the clipboard?

Get the element and pass its textContent to writeText(): await navigator.clipboard.writeText(document.querySelector("#my-div").textContent). For an input or textarea, use .value instead.

Can JavaScript copy to the clipboard without HTTPS?

Not with the modern Clipboard API. The only way on plain HTTP is the older document.execCommand("copy") method with a temporary textarea, which still works in current browsers but is deprecated. My copyText() function uses it automatically when it has to.

Can JavaScript read the clipboard too?

Yes, with navigator.clipboard.readText(), but reading is more restricted than writing. The browser asks the user for permission, or shows a small "Paste" prompt, before your page can see what's on the clipboard. For a "paste" button, call it inside a click handler and handle the case where the user says no.

For the complete list of methods and browser support, see MDN's Clipboard API documentation.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes