Clipboard & Native Drag-and-Drop
Two features in this lesson do something most web APIs never touch: they reach across the wall between your page and the rest of the machine. The clipboard is a shared box that every app on the system can read and write. A file dragged in from the desktop started its life outside the browser entirely. Both are powerful, and because they’re powerful, the browser guards them carefully — behind secure contexts, user gestures, and permission prompts.
We’ll take them in turn. First the async Clipboard API for copy and paste, then native HTML5 drag-and-drop, which is a different animal from the mouse-based dragging you build by hand.
The clipboard is shared, so it’s guarded
Say you want a “Copy” button next to a code snippet. The whole job is one line:
await navigator.clipboard.writeText("npm install chalk");
writeText takes a string and returns a Promise that resolves once the system clipboard holds the new text. Reading back is symmetric:
const text = await navigator.clipboard.readText();
console.log(text); // whatever is on the clipboard now
readText() resolves with a string — the empty string if the clipboard has no text. Both live on navigator.clipboard, an instance of the Clipboard interface. Neither works from a plain HTTP page or a random background timer, and that restriction is the whole point.
Because any of these can fail, treat clipboard calls as fallible. Wrap them and handle the rejection:
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// NotAllowedError: no permission or no user activation
console.warn("Copy failed:", err.name);
return false;
}
}
The rejection you’ll see most is a DOMException named NotAllowedError — thrown when there’s no user activation or the permission was denied.
Checking permission without triggering it
The Permissions API lets you query state for clipboard-read and clipboard-write — but support is uneven. Chromium implements these permission names; Firefox and Safari don’t, and instead show an ephemeral paste confirmation when you call read/readText. So feature-detect defensively rather than relying on a query:
async function canProbablyRead() {
if (!navigator.clipboard?.readText) return false;
try {
const status = await navigator.permissions.query({ name: "clipboard-read" });
return status.state !== "denied";
} catch {
// Firefox/Safari: name not supported. Just try and catch on use.
return true;
}
}
The honest pattern is: attempt the call inside a click handler, and handle rejection. Don’t build UI that assumes you’ll always be allowed.
Reacting to a paste, without the API
There’s an older, gesture-free path that predates the async API: the paste event. When the user pastes into your page (Ctrl+V or the context menu), the event carries a clipboardData object. Because the user initiated it, no permission prompt is needed — the paste is the consent.
document.addEventListener("paste", (event) => {
const text = event.clipboardData.getData("text/plain");
console.log("pasted:", text);
// Grab a pasted screenshot as a file:
for (const item of event.clipboardData.items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
showImage(URL.createObjectURL(file));
}
}
});
This is the right tool when you want to respond to a paste (say, an editor accepting an image) rather than pull from the clipboard on your own schedule. Same story for copy and cut: you can intercept them and call event.clipboardData.setData(...) plus event.preventDefault() to override what lands on the clipboard.
Try it below. Copy some text from anywhere, click into the box, and paste — the handler cancels the default insertion and reports what clipboardData actually offered:
Rich content: ClipboardItem, read, and write
Plain text covers a lot, but the clipboard can hold several representations of one thing at once — an HTML fragment and a plain-text fallback, or a PNG image. That’s what ClipboardItem models: a single clipboard entry with one or more MIME types, each mapping to a Blob (or a string, or a Promise of either).
const item = new ClipboardItem({
"text/plain": "JS Codex",
"text/html": "<strong>JS Codex</strong>",
});
await navigator.clipboard.write([item]);
write() takes an array of ClipboardItems and returns a Promise. Paste that into a rich editor and it gets the bold HTML; paste into a plain text field and it gets JS Codex. You provided both, and the destination picks the format it understands.
Reading rich data is the mirror image. read() resolves with an array of ClipboardItems; each exposes a types array and a getType(mime) method that returns a Promise of a Blob:
const items = await navigator.clipboard.read();
for (const item of items) {
if (item.types.includes("text/html")) {
const blob = await item.getType("text/html");
const html = await blob.text();
console.log(html);
}
}
Copying an image
The classic use case: a “Copy image” button. Fetch the bytes as a Blob, wrap it in a ClipboardItem keyed by its MIME type, and write it:
async function copyImage(url) {
const blob = await fetch(url).then((r) => r.blob());
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob }),
]);
}
Two real-world gotchas here:
Where clipboard support stands
The text methods (writeText/readText) are Baseline widely available and have been for years — safe to use everywhere with a fallback for old browsers. ClipboardItem, read(), and write() reached Baseline more recently (the ClipboardItem constructor became newly available across engines in early 2025), so for rich and image clipboard work, keep feature detection and a plain-text fallback path in place.
if (navigator.clipboard && "write" in navigator.clipboard) {
// rich path
} else {
// fall back to writeText, or the old document.execCommand('copy')
}
Native drag-and-drop: moving data, not pixels
The hand-rolled mouse dragging from earlier moves an element around the screen — you own every pixel. Native HTML5 drag-and-drop is a different mechanism with a different goal: it transfers data from a source to a target. The browser draws the ghost image and manages the gesture; you describe what’s being carried and who can catch it. And crucially, the source can be a file from the operating system — something mouse events can never see.
Making something draggable
Images, links, and selected text are draggable out of the box. Anything else needs the attribute:
<div id="card" draggable="true">Drag me</div>
That alone lets the user pick it up. To make the drag mean something, you attach data in dragstart.
The lifecycle
A drag fires a sequence of events, split between the source element (the thing being dragged) and the target (the thing it’s dragged over and onto):
Here’s each event and where it fires:
dragstart— on the source, once, when the drag begins. The only place you can write todataTransfer. Set your data and effects here.drag— on the source, repeatedly, throughout the drag. Rarely needed.dragenter— on a target, when the pointer first enters it. Good for highlighting.dragover— on a target, repeatedly while the pointer is over it. CallpreventDefault()here or the target refuses drops.dragleave— on a target, when the pointer leaves it. Remove your highlight.drop— on a target, when the user releases over a valid target. Also needspreventDefault(). Read the data here.dragend— on the source, after everything, whether the drop succeeded or not. Clean up. CheckdataTransfer.dropEffectto see what happened.
The one that trips everyone: dragover must preventDefault
The single most common drag-and-drop bug is a drop target that just… won’t accept anything. You wired up a drop handler, dropped onto it, and nothing fired. The reason is a default the browser applies everywhere: most elements reject drops. A drop target has to actively opt in, and the way it opts in is by cancelling the dragover event.
Think of dragover as a gate that is closed by default and springs shut again on every frame. Each time it fires, calling preventDefault() holds the gate open for that moment. Stop calling it and the gate slams — the drop never comes.
Feel the gate yourself. With the box checked, dragover calls preventDefault() and the drop succeeds. Uncheck it, drag the card over, release — nothing happens, and dragend reports why:
DataTransfer: the payload
Every drag carries a DataTransfer object, reachable as event.dataTransfer. It’s the envelope that travels from source to target. You write to it in dragstart with setData(type, value) and read from it in drop with getData(type):
const card = document.getElementById("card");
card.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", card.dataset.id);
e.dataTransfer.effectAllowed = "move";
});
const zone = document.getElementById("dropzone");
zone.addEventListener("dragover", (e) => {
e.preventDefault(); // open the gate
e.dataTransfer.dropEffect = "move";
zone.classList.add("over");
});
zone.addEventListener("dragleave", () => zone.classList.remove("over"));
zone.addEventListener("drop", (e) => {
e.preventDefault(); // don't let the browser handle it
const id = e.dataTransfer.getData("text/plain");
zone.append(document.querySelector(`[data-id="${id}"]`));
zone.classList.remove("over");
});
The type is a MIME string. text/plain, text/html, and text/uri-list (for links) are the standard ones, and you can invent your own like application/x-my-app-card to tag data only your page understands.
zone.addEventListener("dragover", (e) => {
// Only accept our own cards; ignore stray text or files.
if (e.dataTransfer.types.includes("application/x-my-app-card")) {
e.preventDefault();
}
});
Now do it for real. Each card writes its data-id into dataTransfer on dragstart; the zone reads it back in drop and pulls the matching element in:
effectAllowed and dropEffect: the cursor’s story
These two properties control the little badge next to the cursor — the “copy”, “move”, or “link” hint — and they’re a negotiation between source and target:
effectAllowedis set by the source indragstart. It declares which operations are even possible:"copy","move","link", combinations like"copyMove", or"all".dropEffectis set by the target indragover/dragenter. It picks the operation this particular target will perform: one of"copy","move","link", or"none".
The chosen dropEffect must be compatible with effectAllowed, or the drop shows as rejected. The user can nudge the choice with modifier keys (holding Ctrl often forces copy). None of this changes what your drop handler does — that’s your code — but it sets the visual promise the user sees, so keep it honest.
“copy” · “move” · “link”
“copyMove” · “all” …
what’s possible
“copy” · “move”
“link” · “none”
what happens here
incompatible → “no drop”
the user’s feedback
A custom drag image
By default the browser drags a translucent snapshot of the source element. To show something else, call setDragImage(element, offsetX, offsetY) in dragstart. The offsets place the image relative to the cursor:
card.addEventListener("dragstart", (e) => {
const ghost = document.getElementById("drag-ghost");
e.dataTransfer.setDragImage(ghost, 20, 20);
});
The element you pass must be rendered and visible in the document at the moment of the drag (a common trick is an off-screen node positioned with left: -9999px, not display:none). You can also draw to an offscreen <canvas> and hand that over.
Files dragged in from the desktop
This is the capability that makes native drag-and-drop worth the trouble. When the user drags a file from their OS file manager onto your page, the drop event’s dataTransfer.files is a FileList of real File objects — the same type you get from an <input type="file">.
dropzone.addEventListener("dragover", (e) => e.preventDefault());
dropzone.addEventListener("drop", (e) => {
e.preventDefault();
for (const file of e.dataTransfer.files) {
console.log(file.name, file.type, file.size);
// read it, upload it, preview it…
const url = URL.createObjectURL(file);
}
});
Two notes. Remember the dragover preventDefault() — forget it and the browser opens the file instead of handing it to you. And for finer-grained access (including dropped folders), newer browsers expose dataTransfer.items[i].getAsFileSystemHandle(), part of the File System Access API — useful but not universally available, so guard it.
Accessibility: native DnD is not enough on its own
Here’s the uncomfortable truth about native drag-and-drop: it is largely inaccessible. The whole interaction is built around a pointer gesture. There’s no keyboard equivalent baked in — a keyboard-only user can’t pick up a draggable element, and screen readers get little or no signal that a drag is happening or that a drop zone exists.
This is also why so many production drag interfaces — sortable lists, kanban boards, file trees — are built on pointer events rather than the native API. Pointer-based dragging gives you keyboard hooks, custom constraints, touch parity, and full control over announcements. The native API earns its place in exactly two situations:
A pragmatic pattern: use native drag-and-drop for the file-drop zone (because only it can catch OS files), and build your in-page card reordering on pointer events with a proper keyboard fallback. Different jobs, different tools.
Summary
navigator.clipboardis the async clipboard.writeText(str)andreadText()handle plain text and return Promises; both require a secure context, and reading is gated behind a permission or paste prompt. Wrap calls intry/catchforNotAllowedError.- For rich data, build a
ClipboardItemmapping MIME types toBlobs or strings, thenclipboard.write([item]);clipboard.read()gives back items you query withgetType(mime).text/plainandimage/pngare the reliable formats; convert images to PNG and mind Safari’s Promise-wrapping quirk. - The
pasteevent (withevent.clipboardData) is the gesture-free way to react to a user paste, including pasted images viagetAsFile(). - Text clipboard methods are Baseline widely available;
ClipboardItem/read/writearrived more recently — feature-detect and keep a plain-text fallback. - Native drag-and-drop transfers data, not pixels. Mark a source with
draggable="true", stash data indragstartviadataTransfer.setData, and read it back indropviagetData. - A target must
preventDefault()ondragoverto accept drops, and again ondropto stop the browser’s default handling. This is the number-one gotcha. effectAllowed(source) anddropEffect(target) negotiate the cursor feedback;dataTransfer.typeslets a target decide what it accepts without reading values;dataTransfer.filesexposes files dropped from the OS.- Native DnD is not keyboard- or screen-reader-accessible on its own. Use it for OS file drops and cross-window drags; prefer pointer events for accessible in-page dragging, and always ship a non-drag alternative.