A safer, asynchronous path to the clipboard
Clipboard access in browsers has historically meant document.execCommand(): synchronous, DOM-only, and dependent on loosely defined permissions that differ from one browser to the next. That approach works for small snippets of text, but it becomes a liability when a page must decode images, sanitize content, or fetch linked resources that arrive with a pasted document. Blocking the page for those operations—or while the browser asks for permission—makes for a poor experience.
The Async Clipboard API replaces that model with a promise-based design and a well-defined permissions scheme that never blocks the page. Its scope is mainly text and images across most browsers, so checking compatibility for the features you need is essential.
Writing text and images
Copying text uses writeText(), which returns a promise that resolves once the text is on the clipboard:
async function copyPageUrl() {
try {
await navigator.clipboard.writeText(location.href);
console.log('Page URL copied to clipboard');
} catch (err) {
console.error('Failed to copy: ', err);
}
}
writeText() is a convenience wrapper around the more general write(), the method you need for copying images. The image must be a blob—obtained with fetch() followed by blob() on the response, or drawn to a canvas and extracted via toBlob().
The blob is wrapped in a ClipboardItem where the MIME type is the key and the blob is the value. Only one image can be written at a time for now:
try {
const imgURL = '/images/generic/file.png';
const data = await fetch(imgURL);
const blob = await data.blob();
await navigator.clipboard.write([
new ClipboardItem({
// The key is determined dynamically based on the blob's type.
[blob.type]: blob
})
]);
console.log('Image copied.');
} catch (err) {
console.error(err.name, err.message);
}
A promise can stand in for the blob value when the MIME type is known in advance:
try {
const imgURL = '/images/generic/file.png';
await navigator.clipboard.write([
new ClipboardItem({
// Set the key beforehand and write a promise as the value.
'image/png': fetch(imgURL).then(response => response.blob()),
})
]);
console.log('Image copied.');
} catch (err) {
console.error(err.name, err.message);
}
When the user copies and the copy event fires without preventDefault(), the clipboardData property already contains correctly formatted items. Calling preventDefault() empties that collection so custom logic can take over. For example, a page where selecting all and copying should carry only the image, and discard the text:
<!-- The image we want on the clipboard. -->
<img src="kitten.webp" alt="Cute kitten.">
<!-- Some text we're not interested in. -->
<p>Lorem ipsum</p>
document.addEventListener("copy", async (e) => {
// Prevent the default behavior.
e.preventDefault();
try {
// Prepare an array for the clipboard items.
let clipboardItems = [];
// Assume `blob` is the blob representation of `kitten.webp`.
clipboardItems.push(
new ClipboardItem({
[blob.type]: blob,
})
);
await navigator.clipboard.write(clipboardItems);
console.log("Image copied, text ignored.");
} catch (err) {
console.error(err.name, err.message);
}
});
Reading text and images
Reading text is a call to navigator.clipboard.readText(), whose returned promise resolves with the clipboard contents:
async function getClipboardContents() {
try {
const text = await navigator.clipboard.readText();
console.log('Pasted content: ', text);
} catch (err) {
console.error('Failed to read clipboard contents: ', err);
}
}
For images, navigator.clipboard.read() provides a list of ClipboardItem objects. Iterate over that list and, within each item, over its available types, calling getType() with the current type to obtain the corresponding blob:
async function getClipboardContents() {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
const blob = await clipboardItem.getType(type);
console.log(URL.createObjectURL(blob));
}
}
} catch (err) {
console.error(err.name, err.message);
}
}
For users who rely on native shortcuts—ctrl+c and ctrl+v, or the browser menu's Edit > Paste—Chromium exposes read-only files from the clipboard without extra plumbing:
document.addEventListener("paste", async e => {
e.preventDefault();
if (!e.clipboardData.files.length) {
return;
}
const file = e.clipboardData.files[0];
// Read the file's contents, assuming it's a text file.
// There is no way to write back to it.
console.log(await file.text());
});
The existing paste event remains the way to hook into paste actions with the async reading methods. As with copy, call preventDefault() when handling the event yourself:
document.addEventListener('paste', async (e) => {
e.preventDefault();
const text = await navigator.clipboard.readText();
console.log('Pasted text: ', text);
});
Offering multiple formats
Most applications write several data formats on a single copy, since there's no way to know what the target app supports. Many support plain text as a fallback, often exposed to users as a Paste and match style menu option. Writing both an image and its text representation means composing several ClipboardItem entries, each holding its own data:
async function copy() {
const image = await fetch('kitten.png').then(response => response.blob());
const text = new Blob(['Cute sleeping kitten'], {type: 'text/plain'});
const item = new ClipboardItem({
'text/plain': text,
'image/png': image
});
await navigator.clipboard.write([item]);
}
Permissions, security, and context
Unrestricted clipboard access is a serious security risk. A page without limits could silently copy malicious content—a command, or a decompression bomb image—to the clipboard. Unrestricted reads are worse, given that passwords and personal data routinely land there.
The Clipboard API therefore only operates on pages served over HTTPS, and only while the page is the active tab. Writing to the clipboard never prompts for permission, but reading always does. The Permissions API covers both operations: clipboard-write auto-grants to active pages, while clipboard-read must be requested, typically by attempting a read:
const queryOpts = { name: 'clipboard-read', allowWithoutGesture: false };
const permissionStatus = await navigator.permissions.query(queryOpts);
// Will be 'granted', 'denied' or 'prompt':
console.log(permissionStatus.state);
// Listen for changes to the permission state
permissionStatus.onchange = () => {
console.log(permissionStatus.state);
};
The allowWithoutGesture option controls whether a user gesture is needed to cut or paste; because its default differs per browser, set it explicitly. Because the API is asynchronous, a permissions prompt appears transparently, and rejection causes the promise to reject for the page to handle.
One quirk: these examples won't run directly in the browser console, because DevTools is itself the active tab. The workaround is to defer clipboard access with setTimeout() and click into the page before the call executes:
setTimeout(async () => {
const text = await navigator.clipboard.readText();
console.log(text);
}, 2000);
Clipboard access inside iframes
Iframes must opt in via Permissions Policy, passing clipboard-read, clipboard-write, or both depending on what the embedded app needs:
<iframe
src="index.html"
allow="clipboard-read; clipboard-write"
>
</iframe>
Covering older browsers
Support for the Async Clipboard API isn't universal, so check for the presence of
navigator.clipboard before relying on it and keep older methods as a fallback.
The example below shows a paste handler that branches on feature support.
document.addEventListener('paste', async (e) => {
e.preventDefault();
let text;
if (navigator.clipboard) {
text = await navigator.clipboard.readText();
}
else {
text = e.clipboardData.getData('text/plain');
}
console.log('Got pasted text: ', text);
});
Prior to the Async Clipboard API, browsers handled copy and paste inconsistently.
Most supported document.execCommand('copy') and
document.execCommand('paste'), but those methods only operate on text that
exists in the DOM. If the text you want to copy is a computed string, you must
first insert it into the page and select it before calling the command:
button.addEventListener('click', (e) => {
const input = document.createElement('input');
input.style.display = 'none';
document.body.appendChild(input);
input.value = text;
input.focus();
input.select();
const result = document.execCommand('copy');
if (result === 'unsuccessful') {
console.error('Failed to copy text.');
}
input.remove();
});
Try it out
You can experiment with the API through the interactive demos linked below. The first copies and pastes text. Because only PNG images are supported, and only in a subset of browsers, the second demo exercises image round-trips separately.
Further reading
Acknowledgements
The Asynchronous Clipboard API was implemented by Darwin Huang and Gary Kačmarčík, who also provided the demo. Thanks to Kyarik and Kačmarčík for reviewing portions of this article.



