Engineering

The copy button and clipboard permissions

A copy button can report success and paste nothing. Write inside the click, offer HTML and plain text together, and handle the failures a secure context and an iframe policy create.

6 min read

A “Copy” button seems like the simplest thing a page can do. A writer clicks it and the text is on the clipboard. In practice the call can fail in several quiet ways: the page is inside an iframe without the right permission, the browser is not in a secure context, the call was made after an await and lost the user gesture, or the clipboard is locked by another app. When it fails, the button often reports success anyway, and the writer pastes nothing.

Write to the clipboard inside the click, not after a wait

Browsers allow clipboard writes only in response to a user gesture. The gesture is valid for a short time, and an await on a network call can use it up before the write happens. Build the text first, then write it in the same tick as the click. If the text must come from a server, fetch it earlier and keep it ready, or use a ClipboardItem that holds a promise, which lets the browser wait for the value while the gesture is still valid.

Code example
copyButton.addEventListener("click", () => {
  const html = currentSelectionHtml();          // synchronous, read now
  const item = new ClipboardItem({
    "text/html": new Blob([html], { type: "text/html" }),
    "text/plain": new Blob([plainTextOf(html)], { type: "text/plain" }),
  });
  navigator.clipboard.write([item]).then(
    () => showStatus("Copied"),
    () => showStatus("Copy failed. Select the text and press Ctrl+C."),
  );
});

Two things in this sample matter. The HTML and plain text are offered together, so a spreadsheet can read the table and a plain text box can read the words. And the failure path tells the writer what to do, rather than claiming success.

The API exists only in a secure context

navigator.clipboard is undefined on an insecure page, so calling it throws before anything else runs. Check for it, and fall back to document.execCommand("copy") only when the page truly needs it, knowing that the older call is deprecated and can be removed. A local development server on an address other than localhost, or a staging host served over plain HTTP, will fail this way, and the bug looks like a broken button rather than a missing secure context.

Read the permission state, but expect it to change

The browser may ask the writer for permission to read the clipboard, and writing usually needs no prompt. You can query the permission state with navigator.permissions.query to show a helpful message in advance. Treat the answer as a hint. A user can revoke permission between the query and the click, and some browsers do not expose the name you ask for. The write call is the only reliable test, so handle its rejection properly.

Embedded editors need the clipboard policy

An editor inside an iframe on another site needs the clipboard-write permission on the iframe element, or the browser blocks the copy. The same applies to paste with clipboard-read. Add the permission in the embedding page’s markup, then test it from the embedding site, not only from the editor served on its own. This is a deployment detail that is easy to miss, because the editor works fine when you open it directly.

Copy what the writer sees, not the stored markup

When the writer copies a selection, the clipboard should contain the content in the form they selected. Copying the stored document can add hidden attributes, editor-only wrappers and internal identifiers that paste badly into other programs. Serialise only the selected range, strip editor markup, and keep the inline styles that matter for the destination, such as bold and links. Check the result in a plain text editor and in a word processor. Each one shows a different leftover.

A short test list


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.