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

  1. A browser target of Chromium 90+, Firefox 90+ or Safari 15+. The synchronous paste event is universally supported; the async API is not.
  2. A secure context. On http:// (other than localhost) navigator.clipboard is undefined, and the paste event still fires but clipboardData may be restricted.
  3. TypeScript with lib: ["DOM", "DOM.Iterable", "ES2022"] in tsconfig.json.
  4. An element that can receive focus and a paste event — an <input>, a <textarea>, a contenteditable host, or document itself 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.items rather than data.files. clipboardData.files already excludes string entries and is fine for the simple case, but items exposes kind and type before you materialise a File, so you can reject an image/svg+xml paste (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: a text/html fragment containing an <img src>, often a text/plain URL, and the bitmap itself. Only the last has kind === "file". Calling getAsFile() on a string item returns null, 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 produces kind === "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 an await and it returns null — 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 document outlive component unmounts and fire twice after a hot reload; always keep the teardown.
Anatomy of a screenshot paste in clipboardData.items Three clipboard items are listed: two string items for text/html and text/plain that are ignored, and one file item of type image/png that passes the filter and becomes a File. Anatomy of a screenshot paste items[0] · kind="string" · text/html ignored — the editor's img wrapper items[1] · kind="string" · text/plain ignored — a remote image URL items[2] · kind="file" · image/png the only entry that holds bytes kind === "file" getAsFile() File image.png · 1.8 MB Two of the three items carry no bytes at all — getAsFile() on them returns null.
One paste can yield three clipboard items; only the file-kind entry becomes a File, and the HTML flavour must be discarded before it reaches your upload queue.

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.

Object URL lifetime for a pasted image preview A pasted file becomes an object URL, which either gets revoked on image load and frees its entry, or is never revoked and stays resident until the document unloads. Object URL lifetime File from paste bytes in memory URL.createObjectURL blob:https://app/8f2c revoke on load store entry freed never revoked resident until unload Garbage collection never reclaims a blob URL entry — only revokeObjectURL does.
The decoded bitmap survives revocation; the blob URL entry does not, which is exactly the behaviour you want after the preview has painted.

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.

Comparison of the paste event and navigator.clipboard.read A four-row matrix comparing trigger, permission, data shape and failure mode for the synchronous paste event versus the asynchronous clipboard read API. Two clipboard paths, two contracts paste event navigator.clipboard.read() Trigger user presses Ctrl+V any click handler Permission none needed clipboard-read prompt Data shape sync DataTransferItemList async ClipboardItem[] Failure mode silently never fires throws NotAllowedError Ship the paste event as the default; add the async read only behind a button.
The async API buys you a button-triggered paste and costs you a permission prompt plus a rejected promise to handle on every engine.

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.