Drag & Drop Events
Implement native HTML5 drag and drop interactions with React's synthetic event system.
Search across all documentation pages
Implement native HTML5 drag and drop interactions with React's synthetic event system.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Event | Fires When | Fires On | Bubbles | Cancelable |
|---|---|---|---|---|
onDragStart | User starts dragging an element | Dragged element | Yes | Yes |
onDrag | Continuously while element is being dragged | Dragged element | Yes | Yes |
onDragEnd | Drag operation ends (drop or cancel) | Dragged element | Yes | No |
onDragEnter | Dragged element enters a valid drop target | Drop target | Yes | Yes |
onDragOver | Dragged element is over a valid drop target (fires repeatedly) | Drop target | Yes | Yes |
onDragLeave | Dragged element leaves a drop target | Drop target | Yes | No |
onDrop | Element is dropped on a valid drop target | Drop target | Yes | Yes |
You must call
e.preventDefault()ononDragOverto make an element a valid drop target. Without this,onDropwill never fire.
Quick-reference recipe card -- copy-paste ready.
// Basic file drop zone
function FileDropZone() {
const [over, setOver] = useState(false);
return (
<div
onDragOver={(e) => {
e.preventDefault(); // Required to allow drop
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const files = Array.from(e.dataTransfer.files);
console.log("Dropped files:", files);
}}
className={over ? "border-blue-500 bg-blue-50" : "border-gray-300"}
style={{ border: "2px dashed", padding: "2rem", textAlign: "center" }}
>
Drop files here
</div>
);
}When to reach for this: You need file upload via drag and drop, sortable lists, draggable cards between columns, or any visual drag interaction using native browser APIs.
// components/SortableList.tsx
"use client";
import { useState, useRef } from "react";
type Item = { id: string; label: string };
const INITIAL_ITEMS: Item[] = [
{ id: "1", label: "Learn React fundamentals" },
{ id: "2", label: "Build a side project" },
{ id: "3", label: "Write tests" },
{ id: "4", label: "Deploy to production" },
{ id: "5", label: "Monitor and iterate" },
];
export default function SortableList() {
const [items, setItems] = useState<Item[]>(INITIAL_ITEMS);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const handleDragStart = (e: React.DragEvent<HTMLLIElement>, index: number) => {
dragItem.current = index;
setDragIndex(index);
// Set drag data (required for Firefox)
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
// Make the drag ghost semi-transparent
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = "0.4";
}
};
const handleDragOver = (e: React.DragEvent<HTMLLIElement>, index: number) => {
e.preventDefault(); // Required to allow drop
e.dataTransfer.dropEffect = "move";
dragOverItem.current = index;
};
const handleDragEnd = (e: React.DragEvent<HTMLLIElement>) => {
// Reset opacity
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = "1";
}
if (dragItem.current === null || dragOverItem.current === null) {
setDragIndex(null);
return;
}
// Reorder
const newItems = [...items];
const [removed] = newItems.splice(dragItem.current, 1);
newItems.splice(dragOverItem.current, 0, removed);
setItems(newItems);
dragItem.current = null;
dragOverItem.current = null;
setDragIndex(null);
};
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-xl font-bold mb-4">Drag to reorder</h2>
<ul className="space-y-2">
{items.map((item, index) => (
<li
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={`
flex items-center gap-3 px-4 py-3 bg-white border rounded shadow-sm
cursor-grab active:cursor-grabbing
${dragIndex === index ? "opacity-40" : "opacity-100"}
hover:shadow-md transition-shadow
`}
>
<span className="text-gray-400 select-none">:::</span>
<span>{item.label}</span>
</li>
))}
</ul>
</div>
);
}What this demonstrates:
draggable attributeeffectAllowed and dropEffect for correct cursor feedbackdraggable={true} on the source element.dragstart -> drag (repeated) -> dragenter / dragover (on targets) -> drop or dragend.onDragOver handler calls e.preventDefault(). Without this, the browser ignores the drop.e.dataTransfer, which supports multiple MIME types simultaneously.dragover and dragenter, you can only read dataTransfer.types -- the actual data from getData() is only available in drop and dragstart for security reasons.File drop zone with validation:
function ValidatedDropZone() {
const [status, setStatus] = useState<"idle" | "over" | "error">("idle");
const [files, setFiles] = useState<File[]>([]);
const ALLOWED_TYPES = ["image/png", "image/jpeg", "application/pdf"];
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
return (
<div
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setStatus("over");
}}
onDragLeave={() => setStatus("idle")}
onDrop={(e) => {
e.preventDefault();
const dropped = Array.from(e.dataTransfer.files);
const valid = dropped.filter(
(f) => ALLOWED_TYPES.includes(f.type) && f.size <= MAX_SIZE
);
if (valid.length < dropped.length) {
setStatus("error");
setTimeout(() => setStatus("idle"), 2000);
} else {
setStatus("idle");
}
setFiles((prev) => [...prev, ...valid]);
}}
className={`border-2 border-dashed rounded p-8 text-center transition-colors ${
status === "over"
? "border-blue-500 bg-blue-50"
: status === "error"
? "border-red-500 bg-red-50"
: "border-gray-300"
}`}
>
<p>Drop PNG, JPEG, or PDF files (max 5 MB)</p>
{files.length > 0 && (
<ul className="mt-4 text-sm text-left">
{files.map((f, i) => (
<li key={i}>{f.name} ({(f.size / 1024).toFixed(1)} KB)</li>
))}
</ul>
)}
</div>
);
}Custom drag preview / ghost image:
function CustomGhostDrag() {
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
// Create a custom ghost image
const ghost = document.createElement("div");
ghost.textContent = "Moving...";
ghost.style.cssText =
"padding: 8px 16px; background: #3b82f6; color: white; border-radius: 4px; position: absolute; top: -1000px;";
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
// Clean up after the browser captures the ghost
requestAnimationFrame(() => document.body.removeChild(ghost));
};
return (
<div draggable onDragStart={handleDragStart} className="p-4 border rounded cursor-grab">
Drag me (custom ghost)
</div>
);
}Draggable cards between containers:
function KanbanBoard() {
const [columns, setColumns] = useState<Record<string, string[]>>({
todo: ["Task A", "Task B"],
doing: ["Task C"],
done: ["Task D"],
});
const handleDrop = (e: React.DragEvent, targetColumn: string) => {
e.preventDefault();
const data = e.dataTransfer.getData("application/json");
const { sourceColumn, task } = JSON.parse(data);
if (sourceColumn === targetColumn) return;
setColumns((prev) => ({
...prev,
[sourceColumn]: prev[sourceColumn].filter((t) => t !== task),
[targetColumn]: [...prev[targetColumn], task],
}));
};
return (
<div className="flex gap-4">
{Object.entries(columns).map(([colName, tasks]) => (
<div
key={colName}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, colName)}
className="w-48 p-4 bg-gray-100 rounded min-h-[200px]"
>
<h3 className="font-bold mb-2 capitalize">{colName}</h3>
{tasks.map((task) => (
<div
key={task}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(
"application/json",
JSON.stringify({ sourceColumn: colName, task })
);
e.dataTransfer.effectAllowed = "move";
}}
className="p-2 mb-2 bg-white rounded shadow cursor-grab"
>
{task}
</div>
))}
</div>
))}
</div>
);
}Drag with multiple data formats:
function MultiFormatDrag() {
const handleDragStart = (e: React.DragEvent) => {
// Set multiple formats so different drop targets can choose what they need
e.dataTransfer.setData("text/plain", "Hello, world!");
e.dataTransfer.setData("text/html", "<strong>Hello, world!</strong>");
e.dataTransfer.setData(
"application/json",
JSON.stringify({ message: "Hello", timestamp: Date.now() })
);
};
return (
<div draggable onDragStart={handleDragStart} className="p-4 border rounded cursor-grab">
Drag me -- I carry text, HTML, and JSON
</div>
);
}Drag between containers with visual insertion indicator:
function DragWithIndicator() {
const [insertIndex, setInsertIndex] = useState<number | null>(null);
const [items, setItems] = useState(["Item 1", "Item 2", "Item 3"]);
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
// Show indicator above or below based on cursor position
setInsertIndex(e.clientY < midY ? index : index + 1);
};
return (
<ul onDragLeave={() => setInsertIndex(null)}>
{items.map((item, i) => (
<li key={item}>
{insertIndex === i && (
<div className="h-0.5 bg-blue-500 mx-2" />
)}
<div
draggable
onDragOver={(e) => handleDragOver(e, i)}
className="p-3 border-b"
>
{item}
</div>
</li>
))}
{insertIndex === items.length && (
<div className="h-0.5 bg-blue-500 mx-2" />
)}
</ul>
);
}// DragEvent generic specifies the element the handler is attached to
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
e.currentTarget; // HTMLDivElement
e.dataTransfer; // DataTransfer (always available on drag events)
};
// DataTransfer key properties and methods
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.files; // FileList -- files dropped from OS
e.dataTransfer.items; // DataTransferItemList -- all items
e.dataTransfer.types; // readonly string[] -- MIME types available
e.dataTransfer.dropEffect; // "none" | "copy" | "link" | "move"
e.dataTransfer.effectAllowed; // "none" | "copy" | "copyLink" | "copyMove" | "link" | "linkMove" | "move" | "all" | "uninitialized"
// Reading data
const text: string = e.dataTransfer.getData("text/plain");
const json: string = e.dataTransfer.getData("application/json");
// Typed file iteration
const files: File[] = Array.from(e.dataTransfer.files);
files.forEach((file: File) => {
file.name; // string
file.type; // string (MIME)
file.size; // number (bytes)
});
};
// Shorthand handler type
const onDrag: React.DragEventHandler<HTMLLIElement> = (e) => {
// e is React.DragEvent<HTMLLIElement>
};
// Typing drag data with a helper
type DragPayload = { sourceColumn: string; taskId: string };
function setDragPayload(e: React.DragEvent, payload: DragPayload) {
e.dataTransfer.setData("application/json", JSON.stringify(payload));
}
function getDragPayload(e: React.DragEvent): DragPayload {
return JSON.parse(e.dataTransfer.getData("application/json"));
}onDrop never fires without e.preventDefault() on onDragOver -- The browser's default behavior is to reject drops. You must call e.preventDefault() in onDragOver on every element that should accept drops. Fix: Always add onDragOver={(e) => e.preventDefault()} to drop targets.
dataTransfer.getData() returns empty string during dragover -- For security reasons, browsers only allow reading drag data in dragstart and drop events. During dragover and dragenter, only dataTransfer.types is available. Fix: Use dataTransfer.types.includes("application/json") to check what data is available during dragover, and read the actual data in onDrop.
Firefox requires dataTransfer.setData() in dragstart -- Firefox will not start a drag operation unless you call setData with at least one format. Fix: Always call e.dataTransfer.setData("text/plain", "") at minimum in your onDragStart handler.
onDragLeave fires when entering child elements -- Moving the cursor from a drop zone to a child element inside it triggers dragleave on the parent, followed by dragenter on the child. This causes flickering when toggling visual drop states. Fix: Use a counter (increment on dragenter, decrement on dragleave, reset on drop) or check e.currentTarget.contains(e.relatedTarget as Node).
The drag ghost image captures the element at dragstart time -- Any style changes made during onDragStart (like adding a class) may not appear in the ghost because the browser captures the snapshot immediately. Fix: Use e.dataTransfer.setDragImage() for custom ghost images, or apply styles via requestAnimationFrame to ensure they take effect before capture.
draggable on elements with text makes text selection impossible -- Setting draggable={true} on an element prevents users from selecting text inside it with click-drag. Fix: Only set draggable on specific drag handles rather than the entire content area, or toggle draggable based on interaction context.
Mobile browsers have poor or no native drag-and-drop support -- HTML5 Drag and Drop does not work on most mobile browsers (iOS Safari, Chrome Android). Fix: Use pointer events or touch events for mobile drag interactions, or use a library like dnd-kit or @hello-pangea/dnd that handles touch devices.
| Alternative | Use When | Don't Use When |
|---|---|---|
dnd-kit | You need accessible, touch-friendly drag and drop with sortable lists | A simple file drop zone is all you need |
@hello-pangea/dnd (fork of react-beautiful-dnd) | You need smooth animated drag and drop with a battle-tested API | You need to drag between iframes or external windows |
Pointer Events (onPointerDown/Move/Up) | You need custom drag behavior not constrained by HTML5 DnD limitations | Native file drop or cross-window drag is required |
<input type="file"> | You only need file selection, not a visual drop zone | The drag-and-drop UX is a core requirement |
CSS touch-action + pointer events | You need mobile-friendly drag interactions | Desktop-only native DnD is sufficient |
You must call e.preventDefault() in the onDragOver handler on the drop target. The browser's default behavior is to reject drops. Without onDragOver={(e) => e.preventDefault()}, the onDrop event will never fire.
dragstart fires on the dragged element when the user starts draggingdrag fires continuously on the dragged element while draggingdragenter / dragover fire on drop targets as the dragged element moves over themdrop fires on the drop target when the user releasesdragend fires on the dragged element after the operation endsAdd the draggable attribute to the element: <div draggable>Drag me</div>. Then attach an onDragStart handler to set the drag data via e.dataTransfer.setData().
Moving from a parent drop zone to a child triggers dragleave on the parent followed by dragenter on the child, causing visual flicker. Fix this by using a counter (increment on dragenter, decrement on dragleave, reset on drop) or checking e.currentTarget.contains(e.relatedTarget as Node).
For security, browsers only allow reading drag data in dragstart and drop events. During dragover and dragenter, only dataTransfer.types is available. Use dataTransfer.types.includes("application/json") to check data availability during dragover.
// In onDragStart
e.dataTransfer.setData(
"application/json",
JSON.stringify({ sourceColumn: "todo", taskId: "123" })
);
// In onDrop
const data = JSON.parse(e.dataTransfer.getData("application/json"));Firefox requires at least one call to e.dataTransfer.setData() in onDragStart or it will not initiate the drag. Always call e.dataTransfer.setData("text/plain", "") at minimum in your onDragStart handler.
const ALLOWED = ["image/png", "image/jpeg"];
const MAX = 5 * 1024 * 1024;
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
const valid = files.filter(
(f) => ALLOWED.includes(f.type) && f.size <= MAX
);
};effectAllowed is set on the drag source in onDragStart to declare allowed operations ("move", "copy", "link", "all")dropEffect is set on the drop target in onDragOver to indicate the current operation and control the cursor iconThe draggable attribute intercepts click-drag gestures that would normally select text. Fix this by only setting draggable on a specific drag handle element rather than the entire content area.
No. Most mobile browsers (iOS Safari, Chrome Android) have poor or no native drag-and-drop support. Use pointer/touch events or a library like dnd-kit or @hello-pangea/dnd for mobile-friendly drag interactions.
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.files; // FileList
e.dataTransfer.types; // readonly string[]
};
// Typed payload helper
type Payload = { sourceColumn: string; taskId: string };
function getDragPayload(e: React.DragEvent): Payload {
return JSON.parse(e.dataTransfer.getData("application/json"));
}const onDrag: React.DragEventHandler<HTMLLIElement> = (e) => {
// e is React.DragEvent<HTMLLIElement>
e.currentTarget; // HTMLLIElement
e.dataTransfer; // DataTransfer
};Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥