Area: Copy, paste, drag and drop
Related: Browser APIs (map), Blob and Object URLs, Sandboxed iframes
What is it?
Copy/paste and drag-and-drop both move data in several formats at once between apps. The browser represents that data the same way in both cases: a DataTransfer object holding one value per MIME type, plus files.
When you copy a bold word from Google Docs, the clipboard doesn’t hold one thing. It holds something like:
text/plain → "hello"
text/html → "<b style=\"...\">hello</b>"
(plus some private Google format)
The app you paste into picks the richest format it understands. A plain text box takes text/plain. A rich editor takes text/html. That’s the whole model.
One piece of content, several translations of it. The receiver chooses which translation to read.
Two clipboard APIs
There are two ways to use the clipboard, and you’ll meet both.
1. Clipboard events (old, synchronous, still essential)
The copy, cut and paste events fire when the user presses Ctrl/Cmd+C/X/V or uses the menu. You get event.clipboardData, a DataTransfer.
Reading on paste:
editor.addEventListener("paste", (event) => {
const data = event.clipboardData!;
const html = data.getData("text/html");
const text = data.getData("text/plain");
const files = Array.from(data.files); // pasted images or files
if (files.length) handleFiles(files);
else if (html) handleHtml(html);
else handleText(text);
event.preventDefault(); // stop the browser's own paste
});Writing on copy:
document.addEventListener("copy", (event) => {
const selectionText = getSelectedMarkdown();
event.clipboardData!.setData("text/plain", selectionText);
event.clipboardData!.setData("text/html", renderToHtml(selectionText));
event.preventDefault(); // without this, the browser overwrites your data with its own
});Pros: works everywhere, synchronous, you can set any string MIME type, no permission prompts. Cons: only works inside a real copy/paste/cut event. You can’t, for example, copy something after a fetch finishes.
2. The async Clipboard API (navigator.clipboard)
await navigator.clipboard.writeText("https://example.com/share/abc");
const text = await navigator.clipboard.readText();Rich content uses ClipboardItem:
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob([text], { type: "text/plain" }),
"text/html": new Blob([html], { type: "text/html" }),
}),
]);And reading:
for (const item of await navigator.clipboard.read()) {
for (const type of item.types) {
const blob = await item.getType(type);
}
}Rules:
- HTTPS only, and the page usually has to be focused.
- Writing needs a recent user action (a click) in most browsers.
- Reading may show a permission prompt (Chrome) or a small “Paste” button the user must click (Safari, Firefox).
- Limited formats. Browsers reliably support
text/plain,text/htmlandimage/png. Chrome also supports “web custom formats” with awebprefix (like"web application/x-myapp"), but other apps won’t understand them. - Inside iframes (like Microsoft Teams tabs), it also needs
allow="clipboard-write"from the host page. When it’s blocked, show the text in a box and let the user copy by hand.
Safari’s async gotcha
Safari loses the “user activation” if you await anything before write(). For content that takes time to produce, pass a promise into ClipboardItem instead of awaiting first:
// ✅ works in Safari: write() is called synchronously inside the click
button.onclick = () => {
navigator.clipboard.write([
new ClipboardItem({
"text/plain": fetchShareLink().then((link) => new Blob([link], { type: "text/plain" })),
}),
]);
};Paste is untrusted input
HTML pasted from the outside world is a mess, and potentially dangerous:
- Word adds huge amounts of
mso-styles and conditional comments. - Google Docs wraps everything in
<b style="font-weight:normal" id="docs-internal-guid-...">. - Web pages bring their
class,style, tracking pixels, and possibly<script>oronerror=.
So the paste path always:
- Parses the HTML (with
DOMParser, not by inserting it). - Converts it to your own model (markdown, editor nodes) and keeps only what you support.
- Never inserts the raw HTML into your page.
Rich-text editors like ProseMirror/Tiptap do steps 1 and 2 with their schema: anything not in the schema is dropped.
Recognising your own content
When copying out of your own app, you can add a marker so your paste handler knows “this came from us, use the exact original”:
event.clipboardData!.setData("text/html", `<meta name="x-myapp" content="1">${html}`);On paste, if the marker is there, take the fast, lossless path (for example, the original markdown hidden in an attribute). Otherwise treat it as foreign HTML. Many editors do something like this to keep tables, lists and code blocks exact when copying inside the app.
Drag and drop
Native drag-and-drop uses the same DataTransfer, through event.dataTransfer.
Dragging from your page
item.draggable = true;
item.addEventListener("dragstart", (event) => {
event.dataTransfer!.setData("text/plain", item.textContent!);
event.dataTransfer!.setData("application/x-myapp-item", item.id);
event.dataTransfer!.effectAllowed = "move";
});Accepting a drop
zone.addEventListener("dragover", (event) => {
event.preventDefault(); // REQUIRED, or the drop event never fires
event.dataTransfer!.dropEffect = "move";
});
zone.addEventListener("drop", (event) => {
event.preventDefault(); // stop the browser from opening a dropped file
const id = event.dataTransfer!.getData("application/x-myapp-item");
const files = Array.from(event.dataTransfer!.files);
});The rules that confuse everyone
- You must call
preventDefault()indragover(and usuallydragenter) for an element to accept drops. The default is “no drop here”. - Data is locked during the drag. In
dragoveryou can readevent.dataTransfer.types(which formats exist) butgetData()returns"". You only get the actual data indrop. It’s a privacy rule: a page shouldn’t read what you drag across it unless you drop it there. - So in
dragover, decide from types:types.includes("Files")means files are being dragged. dragenteranddragleavefire for every child element you cross, so a highlight on the drop zone flickers. Keep a counter (enter +1, leave -1) or checkevent.relatedTarget.effectAllowed(set by the source) anddropEffect(set by the target) control the cursor: copy, move or link.- If you don’t
preventDefault()ondrop, dropping a file navigates the page to that file. Users lose their work.
Dropped folders
event.dataTransfer.files only lists files. For folders, use items:
for (const item of event.dataTransfer!.items) {
const entry = item.webkitGetAsEntry(); // works in all browsers despite the name
if (entry?.isDirectory) walkDirectory(entry as FileSystemDirectoryEntry);
}Call webkitGetAsEntry() synchronously inside the drop handler. After an await, the DataTransfer is emptied.
Native drag-and-drop vs libraries
Native HTML5 drag-and-drop is needed for dragging files from the desktop and dragging between windows. For dragging things inside your app (reordering a list, moving blocks), it’s awkward: no touch support on many mobile browsers, poor control over the drag preview, odd event behaviour. That’s why libraries exist:
- Pragmatic drag and drop (Atlassian): built on the native API, small, handles files and external drags well.
- dnd-kit: built on pointer events, not native DnD. Good touch and keyboard support, smooth sortable lists.
Many apps use both: native-based for file drops and cross-window drags, pointer-based for sortable UI.
A real-world example
A block-based markdown editor:
- Paste. The paste handler reads
text/htmlfromclipboardDataand converts it to markdown. It also recognises special cases, like a pasted video embed, and turns them into the right block. - Images. The same ingest code takes images from paste (
clipboardData) and from drops (dataTransfer), and readstext/htmltoo, for images that arrive wrapped in HTML. - Copy inside the editor. Lists, tables and other blocks are copied with a marker inside the HTML. Pasting back into the same app finds the marker and uses the original markdown instead of re-converting.
- Copy buttons.
navigator.clipboardbacks every “Copy” button: code blocks, a whole note as markdown, AI answers, recovered drafts, links. Inside an embedded host where opening links can be blocked, the app shows a toast with a copy-link button instead. - Drag and drop. It uses both libraries above: Pragmatic drag and drop and dnd-kit.
Gotchas
- Clipboard events work only inside the event.
navigator.clipboardworks later, but needs HTTPS, focus and often a click. - In
copy, callpreventDefault()or your data is replaced. - Safari: don’t
awaitbeforeclipboard.write(). Pass a promise intoClipboardItem. - Pasted HTML is untrusted. Parse and convert, never insert.
dragoverneedspreventDefault()or nothing can be dropped.getData()is empty untildrop. Usetypesbefore that.- Read
itemsandwebkitGetAsEntry()synchronously indrop. - An unhandled file drop navigates away from the page.
Practice
- Make a box that shows every format on the clipboard when you paste into it (loop over
clipboardData.types). Paste from Word, Google Docs, a web page and a screenshot. Compare. - Write a copy handler that puts both markdown (
text/plain) and HTML on the clipboard. Paste into a plain editor and a rich one. - Build a drop zone that lists dropped files and folders recursively, with a highlight that doesn’t flicker.
- Implement “Copy link” with
navigator.clipboard, and make it work in Safari when the link comes from afetch.
Sources worth reading
- MDN: Clipboard API, ClipboardItem, Element: paste event
- MDN: HTML Drag and Drop API, DataTransfer
- web.dev: Unblocking clipboard access
- Atlassian: Pragmatic drag and drop docs (the “why” section explains the native API’s weaknesses)