React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

dragdropdrag-and-dropfile-uploadsortabledata-transferreact-19typescript

Drag & Drop Events

Implement native HTML5 drag and drop interactions with React's synthetic event system.

Event Reference

EventFires WhenFires OnBubblesCancelable
onDragStartUser starts dragging an elementDragged elementYesYes
onDragContinuously while element is being draggedDragged elementYesYes
onDragEndDrag operation ends (drop or cancel)Dragged elementYesNo
onDragEnterDragged element enters a valid drop targetDrop targetYesYes
onDragOverDragged element is over a valid drop target (fires repeatedly)Drop targetYesYes
onDragLeaveDragged element leaves a drop targetDrop targetYesNo
onDropElement is dropped on a valid drop targetDrop targetYesYes

You must call e.preventDefault() on onDragOver to make an element a valid drop target. Without this, onDrop will 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 draggable attribute
  • Tracking drag source and drop target via refs (avoids re-renders during drag)
  • Setting effectAllowed and dropEffect for 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) -> drop or dragend.
  • An element becomes a valid drop target only when its onDragOver handler calls e.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 dragover and dragenter, you can only read dataTransfer.types -- the actual data from getData() is only available in drop and dragstart for 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

  • 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.

Alternatives

AlternativeUse WhenDon't Use When
dnd-kitYou need accessible, touch-friendly drag and drop with sortable listsA 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 APIYou need to drag between iframes or external windows
Pointer Events (onPointerDown/Move/Up)You need custom drag behavior not constrained by HTML5 DnD limitationsNative file drop or cross-window drag is required
<input type="file">You only need file selection, not a visual drop zoneThe drag-and-drop UX is a core requirement
CSS touch-action + pointer eventsYou need mobile-friendly drag interactionsDesktop-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?
  • dragstart fires on the dragged element when the user starts dragging
  • drag fires continuously on the dragged element while dragging
  • dragenter / dragover fire on drop targets as the dragged element moves over them
  • drop fires on the drop target when the user releases
  • dragend fires 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?
  • 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 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
};