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/html and image/png. Chrome also supports “web custom formats” with a web prefix (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> or onerror=.

So the paste path always:

  1. Parses the HTML (with DOMParser, not by inserting it).
  2. Converts it to your own model (markdown, editor nodes) and keeps only what you support.
  3. 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() in dragover (and usually dragenter) for an element to accept drops. The default is “no drop here”.
  • Data is locked during the drag. In dragover you can read event.dataTransfer.types (which formats exist) but getData() returns "". You only get the actual data in drop. 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.
  • dragenter and dragleave fire for every child element you cross, so a highlight on the drop zone flickers. Keep a counter (enter +1, leave -1) or check event.relatedTarget.
  • effectAllowed (set by the source) and dropEffect (set by the target) control the cursor: copy, move or link.
  • If you don’t preventDefault() on drop, 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/html from clipboardData and 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 reads text/html too, 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.clipboard backs 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.clipboard works later, but needs HTTPS, focus and often a click.
  • In copy, call preventDefault() or your data is replaced.
  • Safari: don’t await before clipboard.write(). Pass a promise into ClipboardItem.
  • Pasted HTML is untrusted. Parse and convert, never insert.
  • dragover needs preventDefault() or nothing can be dropped.
  • getData() is empty until drop. Use types before that.
  • Read items and webkitGetAsEntry() synchronously in drop.
  • An unhandled file drop navigates away from the page.

Practice

  1. 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.
  2. Write a copy handler that puts both markdown (text/plain) and HTML on the clipboard. Paste into a plain editor and a rich one.
  3. Build a drop zone that lists dropped files and folders recursively, with a highlight that doesn’t flicker.
  4. Implement “Copy link” with navigator.clipboard, and make it work in Safari when the link comes from a fetch.

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)