To copy text to the clipboard in JavaScript, call navigator.clipboard.writeText(). To read it back, call navigator.clipboard.readText(). Both are asynchronous and return a promise, both require a secure context (HTTPS or localhost), and the call has to originate from a user gesture like a click. That is the whole job for plain text:
async function teCopy(text) {
await navigator.clipboard.writeText(text);
}
async function tePaste() {
return navigator.clipboard.readText();
}Wire teCopy to a button's click handler and you have a working "copy to clipboard" feature. The rest of this page is the detail behind those four lines: the gotchas that make it fail silently, copying images and rich HTML with ClipboardItem, the permission model, and what to do about the old document.execCommand('copy') you may have inherited.
This is the modern replacement for the bad old days of clipboard hacks. If you remember Flash-based ZeroClipboard or wrapping clipboard.js around a hidden textarea, you can throw all of it out. The asynchronous Clipboard API is native, promise-based, and supported across every current browser.
Copy and paste text
writeText and readText are the two you reach for ninety percent of the time. Both are async, so you await them (or chain .then):
const copyBtn = document.querySelector("#copy");
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText("https://techearl.com");
copyBtn.textContent = "Copied";
} catch (err) {
console.error("Copy failed:", err);
}
});Reading is the mirror image. Pasting is more privacy-sensitive than copying, because a page reading your clipboard could scrape whatever you last copied (a password, a card number), so browsers gate readText harder than writeText:
pasteBtn.addEventListener("click", async () => {
const text = await navigator.clipboard.readText();
document.querySelector("#out").value = text;
});The async bug to avoid
The single most common mistake is writing the handler as an arrow function with await inside but no async on it:
// Broken: `await` is a syntax error here, no `async` on the callback.
copyBtn.addEventListener("click", (e) => {
await navigator.clipboard.writeText("text");
});That does not even parse. Mark the callback async (as in the working example above), or use .then(). It is an easy slip because the surrounding code often is not in an async function, and the editor does not always flag it until runtime.
You need a secure context (HTTPS or localhost)
navigator.clipboard is undefined on plain HTTP. Per the W3C spec the API is exposed only in a secure context, which in practice means HTTPS, or http://localhost during development. This trips people up constantly: the copy button works perfectly on localhost, ships to a staging box served over bare HTTP, and navigator.clipboard is suddenly missing, so the handler throws Cannot read properties of undefined. Guard for it rather than assuming the object exists:
if (!navigator.clipboard) {
// No secure context (or a very old browser). Fall back or disable the button.
console.warn("Clipboard API unavailable; serve over HTTPS or localhost.");
}It must run from a user gesture
Even on HTTPS, you cannot copy or paste on page load, on a timer, or from any code the user did not directly trigger. The call has to happen inside the handler for a real user action: a click, a keydown, that sort of thing. Fire writeText from a setTimeout and it rejects (or, in Firefox, is refused outright because the gesture context is gone). This is by design: a page should not be able to silently rewrite or read your clipboard in the background. Keep the actual writeText/readText call in the gesture's own call stack and do not await something else first that breaks the gesture chain.
Permissions: clipboard-read vs clipboard-write
The two operations map to two different permission names, and mixing them up is a real bug I have seen in the wild:
clipboard-writegoverns writing. In Chromium it is granted automatically to a page in the foreground tab acting on a user gesture, sowriteTextusually just works with no prompt.clipboard-readgoverns reading. This is the gated one. Chromium prompts the user the first time a page callsreadText/read, and Firefox gates reading hard: a page cannot freely read the clipboard at all (the user gets a paste-confirmation affordance instead).
If you query the Permissions API before reading, query clipboard-read, not clipboard-write. They are not interchangeable:
// Correct: querying read permission before a paste.
const status = await navigator.permissions.query({ name: "clipboard-read" });
if (status.state === "granted" || status.state === "prompt") {
const text = await navigator.clipboard.readText();
}Note that clipboard-read and clipboard-write are not universally implemented as Permissions API names (Firefox in particular does not expose them the way Chromium does), so treat a failed permissions.query as "just try the operation and catch the rejection" rather than a hard blocker.
Copy images and HTML with ClipboardItem
For anything that is not plain text (a PNG, rich HTML, both at once), use navigator.clipboard.write() with one or more ClipboardItem objects. Each ClipboardItem maps MIME types to Blobs, so you can put several representations of the same thing on the clipboard at once:
async function teCopyImage(blob) {
const item = new ClipboardItem({ [blob.type]: blob });
await navigator.clipboard.write([item]);
}
// Copy as both HTML and plain text so it pastes well anywhere.
async function teCopyRich(html, text) {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([text], { type: "text/plain" }),
}),
]);
}Browsers commonly support text/plain, text/html, and image/png here. PNG is the safe image type; many engines reject other image formats from write() as a security measure.
Detect what is on the clipboard
read() gives you back an array of ClipboardItems, and each one exposes a types array so you can branch on what is actually there before pulling the blob:
async function tePasteSmart() {
const items = await navigator.clipboard.read();
for (const item of items) {
if (item.types.includes("image/png")) {
const blob = await item.getType("image/png");
return { kind: "image", blob };
}
if (item.types.includes("text/plain")) {
const blob = await item.getType("text/plain");
return { kind: "text", text: await blob.text() };
}
}
}This is the right way to handle a paste that might be a screenshot or might be text: check types, then getType() the one you want.
The deprecated fallback: document.execCommand('copy')
Before the async API there was document.execCommand('copy'), which copied the current DOM selection. It is deprecated and should not be reached for in new code, but you will still see it as a fallback for ancient browsers or non-secure contexts:
// Legacy fallback only. Synchronous, selection-based, deprecated.
function teCopyLegacy(text) {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}In 2026 you almost never need this. Every current browser supports navigator.clipboard.writeText, so the only realistic reason to keep execCommand around is supporting a page that genuinely cannot be served over HTTPS, and the better fix there is to fix the transport.
See also
- JavaScript Promises explained: a complete guide: the Clipboard API is promise-based, so
await,try/catch, and.thenchaining all apply here. - The fetch() API: a practical guide: the other everyday async browser API, with the same "it returns a promise, you have to await it" model.
Sources
Authoritative references this article was fact-checked against.
- Clipboard API — MDN Web Docsdeveloper.mozilla.org
- Clipboard: writeText() method — MDN Web Docsdeveloper.mozilla.org
- Clipboard: readText() method — MDN Web Docsdeveloper.mozilla.org
- ClipboardItem — MDN Web Docsdeveloper.mozilla.org
- Permissions API — MDN Web Docsdeveloper.mozilla.org





