Drag & Drop Events
Implement native HTML5 drag and drop interactions with React's synthetic event system.
Event Reference
| 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.
Recipe
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.
Working Example
// 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:
- Making elements draggable with the
draggableattribute - Tracking drag source and drop target via refs (avoids re-renders during drag)
- Setting
effectAllowedanddropEffectfor correct cursor feedback - Reordering array items on drop with splice
- Visual feedback: reducing opacity on the dragged item
Deep Dive
How It Works
- HTML5 Drag and Drop requires setting
draggable={true}on the source element. - The drag lifecycle is:
dragstart->drag(repeated) ->dragenter/dragover(on targets) ->dropordragend. - An element becomes a valid drop target only when its
onDragOverhandler callse.preventDefault(). Without this, the browser ignores the drop. - Data is passed between drag source and drop target via
e.dataTransfer, which supports multiple MIME types simultaneously. - During
dragoveranddragenter, you can only readdataTransfer.types-- the actual data fromgetData()is only available indropanddragstartfor security reasons.
Variations
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>
);
}TypeScript Notes
// 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"));
}Gotchas
-
onDropnever fires withoute.preventDefault()ononDragOver-- The browser's default behavior is to reject drops. You must calle.preventDefault()inonDragOveron every element that should accept drops. Fix: Always addonDragOver={(e) => e.preventDefault()}to drop targets. -
dataTransfer.getData()returns empty string duringdragover-- For security reasons, browsers only allow reading drag data indragstartanddropevents. Duringdragoveranddragenter, onlydataTransfer.typesis available. Fix: UsedataTransfer.types.includes("application/json")to check what data is available duringdragover, and read the actual data inonDrop. -
Firefox requires
dataTransfer.setData()indragstart-- Firefox will not start a drag operation unless you callsetDatawith at least one format. Fix: Always calle.dataTransfer.setData("text/plain", "")at minimum in youronDragStarthandler. -
onDragLeavefires when entering child elements -- Moving the cursor from a drop zone to a child element inside it triggersdragleaveon the parent, followed bydragenteron the child. This causes flickering when toggling visual drop states. Fix: Use a counter (increment ondragenter, decrement ondragleave, reset ondrop) or checke.currentTarget.contains(e.relatedTarget as Node). -
The drag ghost image captures the element at
dragstarttime -- Any style changes made duringonDragStart(like adding a class) may not appear in the ghost because the browser captures the snapshot immediately. Fix: Usee.dataTransfer.setDragImage()for custom ghost images, or apply styles viarequestAnimationFrameto ensure they take effect before capture. -
draggableon elements with text makes text selection impossible -- Settingdraggable={true}on an element prevents users from selecting text inside it with click-drag. Fix: Only setdraggableon specific drag handles rather than the entire content area, or toggledraggablebased 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-kitor@hello-pangea/dndthat handles touch devices.
Alternatives
| 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 |
FAQs
Why does onDrop never fire on my drop target element?
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.
What is the drag event lifecycle in order?
dragstartfires on the dragged element when the user starts draggingdragfires continuously on the dragged element while draggingdragenter/dragoverfire on drop targets as the dragged element moves over themdropfires on the drop target when the user releasesdragendfires on the dragged element after the operation ends
How do you make an HTML element draggable in React?
Add 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().
Gotcha: Why does onDragLeave fire when the cursor moves over a child element inside the drop zone?
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).
Why does dataTransfer.getData() return an empty string during dragover?
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.
How do you pass structured data between drag source and drop target?
// In onDragStart
e.dataTransfer.setData(
"application/json",
JSON.stringify({ sourceColumn: "todo", taskId: "123" })
);
// In onDrop
const data = JSON.parse(e.dataTransfer.getData("application/json"));Gotcha: Why does drag not start at all in Firefox?
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.
How do you implement a file drop zone with type and size validation?
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
);
};What is the difference between effectAllowed and dropEffect?
effectAllowedis set on the drag source inonDragStartto declare allowed operations ("move","copy","link","all")dropEffectis set on the drop target inonDragOverto indicate the current operation and control the cursor icon
Why does setting draggable on an element prevent text selection inside it?
The 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.
Does HTML5 drag and drop work on mobile browsers?
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.
What is the correct TypeScript type for a drag event handler, and how do you type the payload?
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"));
}How do you use the shorthand React.DragEventHandler type in TypeScript?
const onDrag: React.DragEventHandler<HTMLLIElement> = (e) => {
// e is React.DragEvent<HTMLLIElement>
e.currentTarget; // HTMLLIElement
e.dataTransfer; // DataTransfer
};Related
- Clipboard Events -- Copy, cut, and paste with DataTransfer
- Mouse Events -- Click, hover, and pointer interactions
- Pointer Events -- Unified mouse, touch, and pen input handling