Paste-Image-From-Clipboard Handler
When a user hits Cmd-V with a screenshot in their clipboard, we want to upload it as if they had drag-dropped. This is the 30-line paste handler I keep, including the Safari-only `clipboardData.items` traversal and a graceful HEIC fallback.
By @rajreeves
January 14, 2026
·
Updated August 10, 2026
957 views
28
4.2 (10)
// Listen on the document so any focused element triggers the paste,
// or scope to a specific element by passing it in. We ignore plain-text pastes
// (handled by the textarea natively) and only act on image MIME types.
function onPasteImages(target, onImages) {
function handle(e) {
const items = e.clipboardData && e.clipboardData.items;
if (!items || items.length === 0) return;
const images = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) images.push(file);
}
}
if (images.length === 0) return;
// Calling preventDefault stops the default paste-as-text fallback,
// which would otherwise insert the image's filename into a textarea.
e.preventDefault();
onImages(images);
}
target.addEventListener('paste', handle);
return () => target.removeEventListener('paste', handle);
}
// Drive it with a synthetic event because the playground has no real clipboard.
const listeners = [];
const fakeEl = {
addEventListener(name, fn) { listeners.push([name, fn]); },
removeEventListener() {},
};
const fakePngFile = { name: 'image.png', type: 'image/png', size: 4096 };
const fakeTextItem = { kind: 'string', type: 'text/plain', getAsFile: () => null };
const fakeImageItem = { kind: 'file', type: 'image/png', getAsFile: () => fakePngFile };
onPasteImages(fakeEl, (imgs) => {
console.log('pasted', imgs.length, 'image(s):', imgs.map((f) => f.name));
});
const pasteEvent = {
preventDefault() { console.log('preventDefault called'); },
clipboardData: { items: [fakeTextItem, fakeImageItem] },
};
listeners[0][1](pasteEvent);
// And a no-image paste does nothing.
listeners[0][1]({ preventDefault() { console.log('should not appear'); }, clipboardData: { items: [fakeTextItem] } });The whole pattern hinges on clipboardData.items rather than clipboardData.files, because Safari does not populate files on paste events even when an image is on the clipboard. Walking items and filtering on kind === 'file' plus type.startsWith('image/') works in every desktop browser I have tested. The preventDefault call is what stops the textarea from inserting image.png as plain text, which is the most common bug report: "I pasted a screenshot and nothing happened, but the filename appeared." Returning the cleanup function lets React or any framework remove the listener on unmount.
// On iOS, screenshots paste as image/png but photos from the gallery paste as
// image/heic, which our backend cannot upload. We detect them by sniffing the
// first 12 bytes (the 'ftypheic' / 'ftypheix' magic) and either reject or
// pass-through to a converter.
async function sniffImageType(file) {
const buf = await file.slice(0, 12).arrayBuffer();
const view = new Uint8Array(buf);
const tag = String.fromCharCode(...view.slice(4, 12));
if (tag.startsWith('ftypheic') || tag.startsWith('ftypheix') || tag.startsWith('ftypmif1') || tag.startsWith('ftypmsf1')) {
return 'heic';
}
if (view[0] === 0x89 && view[1] === 0x50 && view[2] === 0x4e) return 'png';
if (view[0] === 0xff && view[1] === 0xd8) return 'jpeg';
if (view[0] === 0x47 && view[1] === 0x49) return 'gif';
if (view[0] === 0x52 && view[1] === 0x49 && view[8] === 0x57) return 'webp';
return 'unknown';
}
async function classifyPastedImages(files) {
const out = [];
for (const f of files) {
const kind = await sniffImageType(f);
out.push({ file: f, sniffed: kind, declared: f.type || '(none)' });
}
return out;
}
// Build fake Files from byte arrays so the demo runs.
function fakeFile(name, bytes) {
const blob = new Blob([new Uint8Array(bytes)]);
return Object.assign(blob, { name });
}
const pngHeader = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0];
const heicHeader = [0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63]; // 'ftypheic'
const jpegHeader = [0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0];
(async () => {
const results = await classifyPastedImages([
fakeFile('a.png', pngHeader),
fakeFile('b.heic', heicHeader),
fakeFile('c.jpg', jpegHeader),
]);
for (const r of results) console.log(r.file.name, '->', r.sniffed, '(declared:', r.declared + ')');
})();Magic-byte sniffing is the only reliable way to tell HEIC apart from PNG, because Safari occasionally lies about the MIME type when a screenshot has been edited in Photos and re-saved. The ftypXXXX tag at offset 4 covers HEIC (heic/heix) and HEIF (mif1/msf1), which together cover everything an iOS user can paste. I keep the conversion path optional: most projects can show a friendly "HEIC is not supported, please paste a JPEG" rather than ship a megabyte of heic2any. Sniffing 12 bytes is fast enough that I run it on every paste without measuring.
