Pasting Images from the Clipboard into an Upload Form
Listen for paste on a focused element, walk event.clipboardData.items for the entry whose kind is "file" and whose type starts with image/, call getAsFile(), rename the resulting image.png, and preview it with URL.createObjectURL() — revoking the URL as soon as the bitmap decodes.
Paste is the fastest path a user has from “I took a screenshot” to “the file is in your form”, and it costs about forty lines. This article sits inside drag-and-drop file uploads within upload fundamentals and browser APIs, because a paste and a drop both hand you a File and should feed the same downstream pipeline. What follows is the part the drop-zone build glosses over: the item filtering, the naming problem, and the two clipboard APIs that behave nothing alike.
When to use this approach
- Your users produce screenshots constantly — bug trackers, support consoles, design review tools, internal admin panels. Paste removes the save-to-disk-then-browse round trip entirely.
- You already have a drop zone and want a second acquisition path for the same
handleFiles(files: File[])function, rather than a separate code path. - You do not need to read the clipboard without a user action. If you want a “Paste from clipboard” button that works on click, you need the async
navigator.clipboard.read()API instead, with its permission prompt — covered below.
Prerequisites
- A browser target of Chromium 90+, Firefox 90+ or Safari 15+. The synchronous
pasteevent is universally supported; the async API is not. - A secure context. On
http://(other thanlocalhost)navigator.clipboardisundefined, and thepasteevent still fires butclipboardDatamay be restricted. - TypeScript with
lib: ["DOM", "DOM.Iterable", "ES2022"]intsconfig.json. - An element that can receive focus and a
pasteevent — an<input>, a<textarea>, acontenteditablehost, ordocumentitself on Chromium and Firefox.
Implementation
The whole handler is synchronous by necessity: clipboardData.items is neutered the moment your listener returns control to the browser, exactly like DataTransfer.items in a drop. Collect the files first, then do anything asynchronous.
export interface PastedImage {
file: File;
previewUrl: string;
}
// MIME → extension. Never trust the extension the clipboard gives you (there isn't one).
const IMAGE_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/tiff": "tif",
"image/svg+xml": "svg",
};
function renamePasted(file: File, index: number): File {
const ext = IMAGE_EXT[file.type] ?? "bin";
// 2026-07-26T14-03-11 — sortable, filesystem-safe, no colons.
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const name = `pasted-${stamp}-${index}.${ext}`;
// A File is an immutable Blob + name; re-wrap rather than mutate.
return new File([file], name, { type: file.type, lastModified: Date.now() });
}
export function attachPasteHandler(
target: HTMLElement | Document,
onImages: (images: PastedImage[]) => void,
): () => void {
const handler = (event: Event): void => {
const data = (event as ClipboardEvent).clipboardData;
if (!data) return;
const files: File[] = [];
// items is live — read it fully before any await or setTimeout.
for (const item of Array.from(data.items)) {
// kind === "string" covers the text/html and text/plain flavours: ignore them.
if (item.kind !== "file") continue;
if (!item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
if (files.length === 0) return; // let plain text paste through untouched
// Stop the browser inserting the bitmap into a contenteditable target.
event.preventDefault();
const images = files.map((file, i) => {
const renamed = renamePasted(file, i);
return { file: renamed, previewUrl: URL.createObjectURL(renamed) };
});
onImages(images);
};
target.addEventListener("paste", handler);
return () => target.removeEventListener("paste", handler);
}
Line-by-line on the critical parts
data.itemsrather thandata.files.clipboardData.filesalready excludes string entries and is fine for the simple case, butitemsexposeskindandtypebefore you materialise aFile, so you can reject animage/svg+xmlpaste (an XSS vector if you render it inline) without allocating anything.item.kind !== "file". A screenshot copied out of Slack, Notion, Word or Google Docs arrives as two or three items: atext/htmlfragment containing an<img src>, often atext/plainURL, and the bitmap itself. Only the last haskind === "file". CallinggetAsFile()on a string item returnsnull, so an unfiltered loop quietly pushes nulls into your array.item.type.startsWith("image/"). Copying a file in Finder or Explorer and pasting it also produceskind === "file"— but with the real filename and any MIME type at all. Widen or narrow this check to match what your endpoint accepts.event.preventDefault()after the filter, not before. Prevent the default only when you actually consumed an image; otherwise you break ordinary text pasting into the same field.getAsFile()must run inside the handler. Move it behind anawaitand it returnsnull— the item list has already been emptied. This is the same trap as handling dropped folders with the DataTransfer API.- The returned function detaches the listener. Paste handlers bound to
documentoutlive component unmounts and fire twice after a hot reload; always keep the teardown.
Why every screenshot arrives as image.png
The operating-system clipboard does not carry a filename for a bitmap. Windows puts a CF_DIB handle on it, macOS puts a public.tiff or public.png pasteboard item — neither has a name, a path or a creation date. When the browser synthesises a File for you it has to invent something, and every engine invented the same thing: image.png.
That means twenty pasted screenshots in one session all claim to be image.png. If you key object storage by the client-supplied name you will overwrite nineteen of them, and if you use Content-Disposition: attachment; filename="image.png" on download every one of them lands in the user’s Downloads folder as image (17).png. Rename before the file leaves the browser, as renamePasted() does — an ISO timestamp plus the index within the paste is enough to make collisions impossible without a round trip to the server for an ID.
Do not derive the extension from the name (there is only ever one) and do not trust file.type as a security control either — it is whatever the source application declared. The clipboard is a weaker source of truth than a picked file, so pasted images deserve the same server-side treatment as any other upload: check the magic bytes with libmagic in Node.js before you store or re-encode them.
One more size surprise: clipboard PNGs are re-encoded from a raw bitmap with no photographic compression, so a 3440×1440 screenshot routinely lands at 6–9 MB where the same content saved as JPEG would be 400 KB. If your users paste on mobile or through a proxy with a body cap, budget for that — see handling large file size limits for where those caps actually bite.
Previewing with an object URL, and revoking it
URL.createObjectURL(file) mints a blob: URL scoped to the document and registers an entry in the blob URL store. That entry keeps the underlying bytes alive until you revoke it or the document unloads — garbage collection will not do it for you. Ten pasted screenshots left unrevoked is 60–90 MB of resident memory in a tab that looks idle.
const list = document.getElementById("paste-previews") as HTMLUListElement;
const queue: File[] = [];
const detach = attachPasteHandler(document, (images) => {
for (const { file, previewUrl } of images) {
const li = document.createElement("li");
const img = new Image(160);
img.alt = file.name;
// Revoke once the bitmap is decoded — the <img> keeps its own reference to the pixels.
const release = (): void => URL.revokeObjectURL(previewUrl);
img.addEventListener("load", release, { once: true });
img.addEventListener("error", release, { once: true });
img.src = previewUrl; // set src AFTER the listeners, or a cached decode can beat you
const caption = document.createElement("span");
caption.textContent = `${file.name} — ${(file.size / 1024).toFixed(0)} KB`;
li.append(img, caption);
list.append(li);
queue.push(file);
}
console.log(`[paste] queued ${queue.length} file(s)`);
});
window.addEventListener("beforeunload", detach, { once: true });
Revoking on load is safe because the decoded bitmap already belongs to the <img> element; the URL string is only needed until the fetch completes. The catch is that the URL is dead afterwards, so if you re-insert that same node later — a virtual DOM re-mount, a lightbox that clones the element — the image renders broken. Keep the File in your queue and mint a fresh object URL on demand rather than caching the string.
Reach for FileReader.readAsDataURL() only if you genuinely need the bytes inline; a data URL inflates the payload by roughly 33% and is held entirely in the DOM string, which is why base64 vs binary encoding matters more here than people expect. For anything larger than a favicon, an object URL wins on both memory and decode time.
The async Clipboard API alternative
navigator.clipboard.read() reads the clipboard without a paste keystroke, which is what you need for an explicit “Paste image” button — useful on touch devices where Ctrl+V does not exist. It returns ClipboardItem[], each with a types array and an async getType(mime) that resolves to a Blob.
export async function readClipboardImages(): Promise<File[]> {
if (!window.isSecureContext) {
throw new Error("clipboard.read() requires HTTPS or localhost");
}
if (typeof navigator.clipboard?.read !== "function") {
throw new Error("this browser has no async clipboard read");
}
// May show a permission prompt or a native "Paste" confirmation chip.
const items = await navigator.clipboard.read();
const out: File[] = [];
for (const item of items) {
const mime = item.types.find((t) => t.startsWith("image/"));
if (!mime) continue; // text-only clipboard entry
const blob = await item.getType(mime);
const ext = IMAGE_EXT[mime] ?? "bin";
out.push(new File([blob], `pasted-${Date.now()}.${ext}`, { type: mime }));
}
return out;
}
const button = document.getElementById("paste-btn") as HTMLButtonElement;
button.addEventListener("click", async () => {
try {
const files = await readClipboardImages();
if (files.length === 0) {
button.textContent = "No image on the clipboard";
return;
}
console.log("[clipboard]", files.map((f) => `${f.name} ${f.size}B`).join(", "));
} catch (err) {
// NotAllowedError, SecurityError, or the two thrown above.
console.warn("[clipboard] read failed:", (err as DOMException).name, (err as Error).message);
button.textContent = "Press Ctrl+V instead";
}
});
The permission model differs per engine and this is where the approach gets expensive. Chromium gates it on the clipboard-read permission: the first call raises a prompt, and a denial is sticky for the origin. Safari never grants a silent read — it renders a native “Paste” chip near the pointer that the user must click, once per call, and the call must happen inside a user gesture or it rejects. Firefox shows its own paste confirmation. None of that fires for the plain paste event, which is why the keystroke path stays the default and the button is the enhancement.
Configuration gotchas
Safari never fires paste on a plain <div>. WebKit dispatches the event only when the focused node is an editable target — an <input>, a <textarea>, or an element with contenteditable. Your Chromium-tested document.addEventListener("paste", …) produces nothing, with no error in the console. Fix: put a visually hidden, focusable capture element inside the drop zone and focus it when the zone is clicked or receives keyboard focus.
<div id="dropzone" tabindex="0">
<span
id="paste-capture"
contenteditable="true"
aria-hidden="true"
style="position:absolute;width:1px;height:1px;overflow:hidden;opacity:0"
></span>
</div>
navigator.clipboard is undefined over plain HTTP. Calling the async path on http://staging.internal throws TypeError: Cannot read properties of undefined (reading 'read'). Fix: guard with window.isSecureContext before touching the object, and keep the paste event as the fallback — it still works on insecure origins.
NotAllowedError: Read permission denied. Chromium rejects clipboard.read() when the clipboard-read permission was denied, and once denied the prompt does not reappear. Safari raises the same error name when the call is not inside a user gesture. Fix: catch by err.name, surface “press Ctrl+V” as the recovery path, and never call read() on page load.
DOMException: Document is not focused. Chromium throws this if the tab loses focus between the click and the promise resolving — commonly when DevTools has focus while you test. Fix: test with DevTools undocked and re-check document.hasFocus() before reading.
NotFoundError: Failed to execute 'getType' on 'ClipboardItem': The type was not found. You passed a MIME string that is not in item.types. The list is exact, not fuzzy: image/jpg is never present, only image/jpeg. Fix: always select the type by searching item.types, as readClipboardImages() does.
Pasted image/svg+xml is executable. An SVG rendered through an object URL in an <img> cannot run script, but the same file served back from your origin and opened directly can. Fix: exclude SVG from the accepted list, or serve user content from a separate origin with Content-Disposition: attachment.
Verification
Chromium and Firefox let you construct a ClipboardEvent with a synthetic DataTransfer, so the whole filter is testable without touching the real clipboard. Paste this into the console with the page open:
const canvas = document.createElement("canvas");
canvas.width = 8;
canvas.height = 8;
canvas.getContext("2d")!.fillRect(0, 0, 8, 8);
const blob = await new Promise<Blob>((r) => canvas.toBlob((b) => r(b!), "image/png"));
const dt = new DataTransfer();
dt.items.add(new File([blob], "image.png", { type: "image/png" }));
dt.items.add('<img src="https://example.test/a.png">', "text/html");
let received: PastedImage[] = [];
const detach = attachPasteHandler(document, (imgs) => {
received = imgs;
});
document.dispatchEvent(
new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }),
);
detach();
console.assert(received.length === 1, `expected 1 image, got ${received.length}`);
console.assert(received[0].file.name !== "image.png", "file was not renamed");
console.assert(received[0].file.name.endsWith(".png"), "extension lost");
console.assert(received[0].previewUrl.startsWith("blob:"), "no object URL minted");
console.log("verified:", received[0].file.name, received[0].file.size, "bytes");
The text/html item is the important half of this test: it proves the filter discards the flavour that Slack and Google Docs attach to every image copy. Safari ignores clipboardData in the ClipboardEvent constructor, so run this check in Chromium or Firefox and verify Safari by hand.
Once the files are in your queue, they are ordinary File objects — send them exactly as you would a picked file, via uploading files with fetch and FormData or straight to object storage with S3 presigned URL workflows.
Frequently Asked Questions
Why is every pasted screenshot called image.png?
Because the OS clipboard stores a raw bitmap with no filename, so the browser invents one. Every screenshot from every source gets the same name, which is why you must rename the File — with a timestamp or a UUID — before it reaches your storage key.
Can I read the clipboard without the user pressing Ctrl+V?
Only through navigator.clipboard.read(), only in a secure context, and only with permission. Chromium prompts for clipboard-read, Safari shows a native paste confirmation per call, and all engines require a user gesture — so treat it as an enhancement behind a button, never as an automatic read.
Why does my <div> drop zone ignore paste in Safari?
WebKit dispatches paste only at editable targets. Add a visually hidden contenteditable span inside the zone and focus it, or bind the listener to a real <input>; Chromium and Firefox are happy with document but Safari is not.
Do I have to call revokeObjectURL if the preview is removed from the DOM?
Yes. The blob URL store entry is independent of the DOM and is only released on revoke or document unload, so removing the <img> alone leaks the bytes. Revoke in the load handler and mint a fresh URL if you need to display the same file again.
Should I still validate a pasted image on the server?
Absolutely. file.type comes from whichever application filled the clipboard and is trivially wrong or spoofed. Run the same magic-byte and dimension checks you apply to uploaded files before storing or re-encoding.