Copy to clipboard with JavaScript in a single HTML file

One call, navigator.clipboard.writeText, copies text, and it belongs inside a click handler. The example below adds the fallback and the confirmation, and the sections after it cover why a copy fails.

To copy text to the clipboard with JavaScript, call navigator.clipboard.writeText(text) from inside a click handler and wait for the promise it returns. That is the whole call.

Everything else on this page is the single HTML file around it: a fallback, a visible confirmation, and the reasons a copy fails.

Try it first. Press Copy, then paste into the box. The status line names the route that ran.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Copy to clipboard</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
  #src { flex: 1 1 200px; margin: 0; padding: 12px 14px; background: #fff; border: 1px solid #d9dde4; border-radius: 8px; font: 15px ui-monospace, Consolas, monospace; }
  button { min-width: 96px; padding: 11px 16px; font: 600 15px system-ui, sans-serif; color: #fff; background: #2563eb; border: 0; border-radius: 8px; cursor: pointer; }
  button:active { background: #1d4ed8; }
  #status { min-height: 22px; margin: 10px 0 14px; font-size: 14px; color: #0f5132; }
  #status.fail { color: #9a3412; }
  label { display: block; font-size: 13px; color: #4b5563; margin-bottom: 4px; }
  #paste { width: 100%; box-sizing: border-box; height: 64px; padding: 8px 10px; font: 14px system-ui, sans-serif; border: 1px dashed #9aa3b2; border-radius: 8px; resize: none; }
</style>
</head>
<body>
<div class="row">
  <p id="src">npm install left-pad</p>
  <button id="copy">Copy</button>
</div>
<div id="status" role="status"></div>

<label for="paste">Paste test: press Ctrl+V (or long-press, Paste) in the box below</label>
<textarea id="paste"></textarea>

<script>
  async function copyText(text) {
    try {
      await navigator.clipboard.writeText(text);   // modern path, returns a promise
      return 'Clipboard API';
    } catch (err) {
      // refused or missing: copy from a temporary textarea instead
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
      document.body.appendChild(ta);
      ta.focus();
      ta.select();
      const ok = document.execCommand('copy');     // old path, returns true or false
      ta.remove();
      if (!ok) throw err;
      return 'execCommand fallback';
    }
  }

  const btn = document.getElementById('copy');
  const status = document.getElementById('status');

  btn.addEventListener('click', async () => {
    try {
      const how = await copyText(document.getElementById('src').textContent);
      status.className = '';
      status.textContent = 'Copied using the ' + how + '. Now paste it below.';
      btn.textContent = 'Copied';
    } catch (err) {
      status.className = 'fail';
      status.textContent = 'Copy was blocked (' + err.name + '). Select the text and press Ctrl+C.';
      btn.textContent = 'Failed';
    }
    setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
  });
</script>
</body>
</html>
A text, a button, and a paste box to check the result. The status line says which route copied.

The function returns a short label for the route it used, so the page can report it. The same copyText function is the one to copy into your own page.

How the code works, step by step

Every working copy button follows the same five steps.

  1. Keep the text readable. It sits in an element or a variable, so the handler can pass a plain string.
  2. Listen for a click. The copy runs because a person pressed the button.
  3. Call writeText. It returns a promise that resolves once the clipboard has been updated.
  4. Catch the refusal. If writing is not allowed, the promise rejects with NotAllowedError.
  5. Tell the reader. Change the label to Copied, or explain what to do instead.
The path from a click to a Copied label, with the fallback and the manual message below.
The path from a click to a Copied label, with the fallback and the manual message below.

The fallback builds a temporary textarea, fills it with the text, calls focus() and select(), and runs document.execCommand('copy'). MDN notes that select() does not necessarily focus the element, so the code calls focus() first.

Why it works on a click and fails on page load

Browsers treat clipboard writes as something a person should trigger. MDN describes the rule as a mix of permission and recent user interaction, and it differs by browser.

The same call fails when nothing triggered it and works inside a click listener.
The same call fails when nothing triggered it and works inside a click listener.
  • Chromium browsers: writing needs the clipboard-write permission or a recent user interaction.
  • Firefox and Safari: writing needs a recent user interaction.
  • Secure context: the Clipboard API is available on https pages, on localhost and on file URLs. A plain http address is not secure.
  • Frames: a framed page is secure only when its parent is. In Chromium, an iframe also needs a Permissions-Policy that allows clipboard-write.

The frame rule is why a copy button can work in your own file and fail once the page is shown inside someone else's frame. Keep the fallback, and keep the status line.

Three ways to copy, compared

Three routes exist, and they copy different things.

Route What it copies Notes
writeText A string you pass in Returns a promise. MDN recommends it over execCommand
execCommand('copy') The current selection Deprecated. Returns true only during a user interaction
copy event Whatever you set Runs when the person copies with Ctrl+C or the menu

The third route changes what a normal copy puts on the clipboard. It does not need a button:

document.addEventListener('copy', (e) => {
  const picked = document.getSelection().toString();
  e.clipboardData.setData('text/plain', picked + ' (from my page)');
  e.preventDefault();  // use our text instead of the default
});

Copy formatted text, not just text

writeText puts plain text on the clipboard, so a pasted table becomes lines of text. To keep the formatting, write a ClipboardItem that carries both an HTML version and a plain version.

await navigator.clipboard.write([new ClipboardItem({
  'text/html': new Blob([html], { type: 'text/html' }),
  'text/plain': new Blob([text], { type: 'text/plain' }),
})]);

MDN says browsers commonly support writing text, HTML and PNG image data. ClipboardItem.supports(type) lets a page check a type before it writes. The next example offers both copies of one block, and prints the formats it finds when you paste.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Plain text vs formatted copy</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  #block { background: #fff; border: 1px solid #d9dde4; border-radius: 8px; padding: 10px 14px; }
  #block h3 { margin: 4px 0 8px; font-size: 17px; color: #1e40af; }
  #block table { border-collapse: collapse; width: 100%; font-size: 14px; }
  #block th, #block td { border: 1px solid #c9ced8; padding: 5px 8px; text-align: left; }
  #block th { background: #e8eefc; }
  .btns { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 8px; }
  button { padding: 10px 14px; font: 600 14px system-ui, sans-serif; color: #fff; background: #2563eb; border: 0; border-radius: 8px; cursor: pointer; }
  button.alt { background: #475569; }
  #status { min-height: 20px; font-size: 14px; color: #0f5132; }
  #status.fail { color: #9a3412; }
  label { display: block; font-size: 13px; color: #4b5563; margin: 10px 0 4px; }
  #paste { min-height: 100px; padding: 8px 10px; background: #fff; border: 1px dashed #9aa3b2; border-radius: 8px; font-size: 14px; }
  #types { font-size: 13px; color: #374151; margin-top: 6px; }
</style>
</head>
<body>
<div id="block">
  <h3>Order 1042</h3>
  <table>
    <tr><th>Item</th><th>Qty</th></tr>
    <tr><td>Tea</td><td>2</td></tr>
    <tr><td>Mug</td><td>1</td></tr>
  </table>
</div>

<div class="btns">
  <button id="plain" class="alt">Copy as plain text</button>
  <button id="rich">Copy with formatting</button>
</div>
<div id="status" role="status"></div>

<label for="paste">Paste test: press Ctrl+V (or long-press, Paste) in the box</label>
<div id="paste" contenteditable="true"></div>
<div id="types"></div>

<script>
  const block = document.getElementById('block');
  const status = document.getElementById('status');

  function report(msg, bad) {
    status.className = bad ? 'fail' : '';
    status.textContent = msg;
  }

  async function copyPlain(text) {
    try {
      await navigator.clipboard.writeText(text);
    } catch (err) {
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
      document.body.appendChild(ta);
      ta.focus();
      ta.select();
      const ok = document.execCommand('copy');
      ta.remove();
      if (!ok) throw err;
    }
  }

  async function copyRich(el) {
    try {
      // one item, two formats: apps pick the richest one they understand
      await navigator.clipboard.write([new ClipboardItem({
        'text/html': new Blob([el.outerHTML], { type: 'text/html' }),
        'text/plain': new Blob([el.innerText], { type: 'text/plain' }),
      })]);
      return 'ClipboardItem';
    } catch (err) {
      // fallback: select the rendered element, then copy the selection
      const range = document.createRange();
      range.selectNodeContents(el);
      const sel = getSelection();
      sel.removeAllRanges();
      sel.addRange(range);
      const ok = document.execCommand('copy');
      sel.removeAllRanges();
      if (!ok) throw err;
      return 'execCommand on a selection';
    }
  }

  document.getElementById('plain').addEventListener('click', async () => {
    try {
      await copyPlain(block.innerText);
      report('Copied plain text. Paste it: the table becomes tab-separated lines.');
    } catch (err) {
      report('Plain copy was blocked (' + err.name + ').', true);
    }
  });

  document.getElementById('rich').addEventListener('click', async () => {
    try {
      const how = await copyRich(block);
      report('Copied with formatting using ' + how + '. Paste it: the table stays a table.');
    } catch (err) {
      report('Formatted copy was blocked (' + err.name + ').', true);
    }
  });

  // paste event: list which formats the clipboard held
  document.getElementById('paste').addEventListener('paste', (e) => {
    document.getElementById('types').textContent = 'Clipboard held: ' + Array.from(e.clipboardData.types).join(', ');
  });
</script>
</body>
</html>
Copy the same block as plain text or with formatting, then paste into the editable box to compare.
Plain text pastes as lines. Text plus HTML pastes as a table.
Plain text pastes as lines. Text plus HTML pastes as a table.

The fallback in this example selects the rendered block and copies the selection, which also carries formatting. Pasting here uses the paste event, where clipboardData.types lists what the clipboard held. A contenteditable div is the box that accepts the pasted table.

In the console and in React

In the browser console, DevTools has a helper: copy(object) copies a string representation of the object to the clipboard. Type it at the console prompt. Code on your page should use navigator.clipboard.

In React, the call is the same and only its place changes. Put it in the button's onClick handler:

<button onClick={() => copyText(text)}>Copy</button>

Pass a function. The React docs point out that onClick={handleClick()} calls the function during rendering, so a copy written that way runs on every render.

A finished example: a reply shelf

Here the same copyText function sits behind three buttons. The snippets fill in the customer name and order number as you type, each button reports its own result, and a failed copy selects the text for Ctrl+C.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reply shelf</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
  label { font-size: 13px; color: #4b5563; display: block; }
  input { width: 100%; box-sizing: border-box; margin-top: 3px; padding: 9px 10px; font: 15px system-ui, sans-serif; border: 1px solid #c9ced8; border-radius: 8px; }
  .snip { background: #fff; border: 1px solid #d9dde4; border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; }
  .snip h4 { margin: 0 0 4px; font-size: 14px; }
  .snip p { margin: 0 0 8px; font-size: 14px; line-height: 1.45; }
  .snip button { min-width: 108px; padding: 9px 14px; font: 600 14px system-ui, sans-serif; color: #fff; background: #2563eb; border: 0; border-radius: 8px; cursor: pointer; }
  .snip button.done { background: #15803d; }
  .snip button.fail { background: #c2410c; }
  .snip.hit { border-color: #2563eb; }
  #paste { width: 100%; box-sizing: border-box; height: 70px; padding: 8px 10px; font: 14px system-ui, sans-serif; border: 1px dashed #9aa3b2; border-radius: 8px; resize: none; }
  @media (max-width: 420px) { .fields { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="fields">
  <label>Customer name <input id="name" value="Dana"></label>
  <label>Order number <input id="order" value="1042"></label>
</div>

<div id="shelf"></div>

<label for="paste">Paste test: press Ctrl+V (or long-press, Paste) in the box</label>
<textarea id="paste"></textarea>

<script>
  const SNIPPETS = [
    { title: 'Shipped', tpl: 'Hi {name}, order #{order} has shipped. You will get a tracking link by email.' },
    { title: 'Delayed', tpl: 'Hi {name}, order #{order} is running a day late. Sorry for the wait.' },
    { title: 'Refund', tpl: 'Hi {name}, the refund for order #{order} is on its way.' },
  ];

  const shelf = document.getElementById('shelf');
  const fields = { name: document.getElementById('name'), order: document.getElementById('order') };

  // fill {name} and {order} from the inputs
  const fill = (tpl) => tpl.replace(/\{(\w+)\}/g, (_, k) => fields[k].value.trim() || '…');

  async function copyText(text) {
    try {
      await navigator.clipboard.writeText(text);
    } catch (err) {
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
      document.body.appendChild(ta);
      ta.focus();
      ta.select();
      const ok = document.execCommand('copy');
      ta.remove();
      if (!ok) throw err;
    }
  }

  SNIPPETS.forEach((s) => {
    const box = document.createElement('div');
    box.className = 'snip';
    box.innerHTML = '<h4></h4><p></p><button type="button">Copy</button>';
    box.querySelector('h4').textContent = s.title;
    s.p = box.querySelector('p');
    s.btn = box.querySelector('button');
    shelf.appendChild(box);

    s.btn.addEventListener('click', async () => {
      try {
        await copyText(s.p.textContent);
        s.btn.textContent = 'Copied';
        s.btn.className = 'done';
      } catch (err) {
        // last resort: highlight the text so the reader can press Ctrl+C
        const r = document.createRange();
        r.selectNodeContents(s.p);
        getSelection().removeAllRanges();
        getSelection().addRange(r);
        s.btn.textContent = 'Press Ctrl+C';
        s.btn.className = 'fail';
      }
      setTimeout(() => { s.btn.textContent = 'Copy'; s.btn.className = ''; }, 1500);
    });
  });

  function render() { SNIPPETS.forEach((s) => { s.p.textContent = fill(s.tpl); }); }
  Object.values(fields).forEach((f) => f.addEventListener('input', render));
  render();
</script>
</body>
</html>
Edit the name and order number, press Copy on a reply, then paste it in the box.
  • Fill the text before copying. Rebuild each snippet on every input event, and copy the visible text.
  • Per-button feedback. Each button changes to Copied for about a second and a half, then restores.
  • Last resort. If both routes fail, select the text and ask for Ctrl+C.

For button placement, table rows and labels, see HTML copy button. It covers the button itself, and this page covers the copy function behind it.

Reading the clipboard is different

readText() returns a promise with the clipboard's text, but access to read has to be allowed. When it is not, the promise rejects with NotAllowedError. Chromium browsers may ask for a permission, and Firefox and Safari may show a small Paste menu.

For most pages the simpler design is to let the person paste with Ctrl+V and read the paste event, as the example above does.

When it does not work

Symptom Cause Fix
Click does nothing on an http page The Clipboard API needs a secure context Serve over https, or keep the fallback
NotAllowedError in the console No user interaction, or the frame lacks permission Call it from a click listener; allow clipboard-write in frames
In React, it copies at page load onClick={copy()} runs during render Pass a function: onClick={() => copy()}
Fallback returns false No user interaction, or nothing was selected Run it in the click handler, after select()
A table pastes as plain lines Only text/plain was written Write text/html with a ClipboardItem
Button works locally, not in a frame The frame does not allow clipboard writes Keep the fallback and show its result

A copy button has to be pressed to be believed, and a screenshot cannot be pressed. The page also needs its script to travel with it, which self-contained HTML explains.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can press the buttons themselves. If you change the code later, the same link shows the new version.

Open the link yourself and press each button once. The status line tells you which route ran, and that is the route your readers will get too.

Questions people ask

How do I copy text to the clipboard in JavaScript?

Call navigator.clipboard.writeText(text) inside a click handler and wait for the promise it returns. When the promise resolves, the clipboard has been updated. When it rejects, writing was not allowed, so show a message or use a fallback.

Why do I get NotAllowedError when I copy?

writeText rejects with NotAllowedError when writing to the clipboard is not allowed. Chromium browsers need either the clipboard-write permission or a recent user interaction. Firefox and Safari need the user interaction. Move the call into a click handler. In Chromium, an iframe also needs a Permissions-Policy that allows clipboard-write.

Does copy to clipboard work without https?

The Clipboard API is limited to secure contexts. That includes https pages, localhost and file URLs, and excludes a plain http address. On a plain http page, use the older execCommand fallback.

How do I copy to the clipboard from the browser console?

Type copy(object) in the DevTools console. According to the Chrome DevTools documentation, it copies a string representation of the object to the clipboard. Code on a page should use navigator.clipboard instead.

How do I copy to clipboard in React?

Write the same navigator.clipboard.writeText call inside the onClick handler of the button. Pass a function, as in onClick={() => copyText(text)}. Writing onClick={copyText(text)} calls it while the component renders.

Keep reading