Eventos Arrastrar y Soltar
Implementa interacciones nativas de arrastrar y soltar HTML5 con el sistema de eventos sintéticos de React.
Busca en todas las páginas de la documentación
Implementa interacciones nativas de arrastrar y soltar HTML5 con el sistema de eventos sintéticos de React.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Evento | Se dispara cuando | Se dispara en | Burbuja | Cancelable |
|---|---|---|---|---|
onDragStart | El usuario comienza a arrastrar un elemento | Elemento arrastrado | Sí | Sí |
onDrag | Continuamente mientras el elemento se está arrastrando | Elemento arrastrado | Sí | Sí |
onDragEnd | La operación de arrastrar termina (soltar o cancelar) | Elemento arrastrado | Sí | No |
onDragEnter | El elemento arrastrado entra en un destino de soltar válido | Destino de soltar | Sí | Sí |
onDragOver | El elemento arrastrado está sobre un destino de soltar válido (se dispara repetidamente) | Destino de soltar | Sí | Sí |
onDragLeave | El elemento arrastrado sale de un destino de soltar | Destino de soltar | Sí | No |
onDrop | El elemento se suelta en un destino de soltar válido | Destino de soltar | Sí | Sí |
Debes llamar a
e.preventDefault()enonDragOverpara hacer que un elemento sea un destino de soltar válido. Sin esto,onDropnunca se ejecutará.
Tarjeta de referencia rápida -- lista para copiar y pegar.
// Zona de soltar archivos básica
function FileDropZone() {
const [over, setOver] = useState(false);
return (
<div
onDragOver={(e) => {
e.preventDefault(); // Requerido para permitir soltar
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const files = Array.from(e.dataTransfer.files);
console.log("Archivos soltados:", files);
}}
className={over ? "border-blue-500 bg-blue-50" : "border-gray-300"}
style={{ border: "2px dashed", padding: "2rem", textAlign: "center" }}
>
Suelta archivos aquí
</div>
);
}Cuándo usarlo: Necesitas carga de archivos a través de arrastrar y soltar, listas ordenables, tarjetas arrastrables entre columnas, o cualquier interacción de arrastrar visual usando APIs de navegador nativas.
// components/SortableList.tsx
"use client";
import { useState, useRef } from "react";
type Item = { id: string; label: string };
const INITIAL_ITEMS: Item[] = [
{ id: "1", label: "Aprende fundamentos de React" },
{ id: "2", label: "Construye un proyecto paralelo" },
{ id: "3", label: "Escribe pruebas" },
{ id: "4", label: "Despliega a producción" },
{ id: "5", label: "Monitorea e itera" },
];
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);
// Establece datos de arrastrar (requerido para Firefox)
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
// Hace la imagen fantasma de arrastrar semi-transparente
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = "0.4";
}
};
const handleDragOver = (e: React.DragEvent<HTMLLIElement>, index: number) => {
e.preventDefault(); // Requerido para permitir soltar
e.dataTransfer.dropEffect = "move";
dragOverItem.current = index;
};
const handleDragEnd = (e: React.DragEvent<HTMLLIElement>) => {
// Reinicia opacidad
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = "1";
}
if (dragItem.current === null || dragOverItem.current === null) {
setDragIndex(null);
return;
}
// Reordena
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">Arrastra para reordenar</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>
);
}Lo que esto demuestra:
draggableeffectAllowed y dropEffect para retroalimentación correcta del cursordraggable={true} en el elemento origen.dragstart -> drag (repetido) -> dragenter / dragover (en destinos) -> drop o dragend.onDragOver llama a e.preventDefault(). Sin esto, el navegador ignora el soltar.e.dataTransfer, que soporta múltiples tipos MIME simultáneamente.dragover y dragenter, solo puedes leer dataTransfer.types -- los datos reales de getData() solo están disponibles en drop y dragstart por razones de seguridad.Zona de soltar archivos con validación:
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>Suelta archivos PNG, JPEG o PDF (máx. 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>
);
}Vista previa de arrastrar personalizada / imagen fantasma:
function CustomGhostDrag() {
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
// Crea una imagen fantasma personalizada
const ghost = document.createElement("div");
ghost.textContent = "Moviendo...";
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);
// Limpia después de que el navegador captura la imagen fantasma
requestAnimationFrame(() => document.body.removeChild(ghost));
};
return (
<div draggable onDragStart={handleDragStart} className="p-4 border rounded cursor-grab">
Arrastrame (imagen fantasma personalizada)
</div>
);
}Tarjetas arrastrables entre contenedores:
function KanbanBoard() {
const [columns, setColumns] = useState<Record<string, string[]>>({
todo: ["Tarea A", "Tarea B"],
doing: ["Tarea C"],
done: ["Tarea 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>
);
}Arrastrar con múltiples formatos de datos:
function MultiFormatDrag() {
const handleDragStart = (e: React.DragEvent) => {
// Establece múltiples formatos para que diferentes destinos de soltar puedan elegir lo que necesitan
e.dataTransfer.setData("text/plain", "¡Hola, mundo!");
e.dataTransfer.setData("text/html", "<strong>¡Hola, mundo!</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">
Arrastrame -- Llevo texto, HTML y JSON
</div>
);
}Arrastrar entre contenedores con indicador de inserción visual:
function DragWithIndicator() {
const [insertIndex, setInsertIndex] = useState<number | null>(null);
const [items, setItems] = useState(["Elemento 1", "Elemento 2", "Elemento 3"]);
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
// Muestra indicador arriba o abajo según la posición del cursor
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>
);
}// El genérico DragEvent especifica el elemento al que está adjunto el manejador
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
e.currentTarget; // HTMLDivElement
e.dataTransfer; // DataTransfer (siempre disponible en eventos drag)
};
// Propiedades y métodos clave de DataTransfer
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.files; // FileList -- archivos soltados del SO
e.dataTransfer.items; // DataTransferItemList -- todos los elementos
e.dataTransfer.types; // readonly string[] -- tipos MIME disponibles
e.dataTransfer.dropEffect; // "none" | "copy" | "link" | "move"
e.dataTransfer.effectAllowed; // "none" | "copy" | "copyLink" | "copyMove" | "link" | "linkMove" | "move" | "all" | "uninitialized"
// Leyendo datos
const text: string = e.dataTransfer.getData("text/plain");
const json: string = e.dataTransfer.getData("application/json");
// Iteración de archivo tipada
const files: File[] = Array.from(e.dataTransfer.files);
files.forEach((file: File) => {
file.name; // string
file.type; // string (MIME)
file.size; // number (bytes)
});
};
// Tipo de manejador abreviado
const onDrag: React.DragEventHandler<HTMLLIElement> = (e) => {
// e es React.DragEvent<HTMLLIElement>
};
// Tipado de datos de arrastrar con un ayudante
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 nunca se dispara sin e.preventDefault() en onDragOver -- El comportamiento predeterminado del navegador es rechazar soltadas. Debes llamar a e.preventDefault() en onDragOver en cada elemento que deba aceptar soltadas. Solución: Siempre agrega onDragOver={(e) => e.preventDefault()} a los destinos de soltar.
dataTransfer.getData() devuelve una cadena vacía durante dragover -- Por razones de seguridad, los navegadores solo permiten leer datos de arrastrar en eventos dragstart y drop. Durante dragover y dragenter, solo dataTransfer.types está disponible. Solución: Usa dataTransfer.types.includes("application/json") para verificar qué datos están disponibles durante dragover, y lee los datos reales en onDrop.
Firefox requiere dataTransfer.setData() en dragstart -- Firefox no iniciará una operación de arrastrar a menos que llames a setData con al menos un formato. Solución: Siempre llama a e.dataTransfer.setData("text/plain", "") como mínimo en tu manejador onDragStart.
onDragLeave se dispara al entrar en elementos hijo -- Mover el cursor de una zona de soltar a un elemento hijo dentro de ella dispara dragleave en el padre, seguido de dragenter en el hijo. Esto causa parpadeo al alternar estados de soltar visuales. Solución: Usa un contador (incrementa en dragenter, decrementa en dragleave, reinicia en drop) o verifica e.currentTarget.contains(e.relatedTarget as Node).
La imagen fantasma de arrastrar captura el elemento en el tiempo de dragstart -- Cualquier cambio de estilo hecho durante onDragStart (como agregar una clase) puede no aparecer en la imagen fantasma porque el navegador captura la instantánea inmediatamente. Solución: Usa e.dataTransfer.setDragImage() para imágenes fantasma personalizadas, o aplica estilos a través de requestAnimationFrame para asegurar que surtan efecto antes de la captura.
draggable en elementos con texto hace imposible seleccionar texto -- Configurar draggable={true} en un elemento previene que los usuarios seleccionen texto dentro de él con clic-arrastrar. Solución: Solo configura draggable en asas de arrastrar específicas en lugar de toda el área de contenido, o alterna draggable basado en el contexto de interacción.
Los navegadores móviles tienen soporte pobre o nulo para arrastrar y soltar nativo -- HTML5 Drag and Drop no funciona en la mayoría de navegadores móviles (iOS Safari, Chrome Android). Solución: Usa eventos pointer o touch para interacciones de arrastrar móvil, o usa una biblioteca como dnd-kit o @hello-pangea/dnd que maneja dispositivos táctiles.
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
dnd-kit | Necesitas arrastrar y soltar accesible, amigable con el tacto con listas ordenables | Una zona de soltar de archivos simple es todo lo que necesitas |
@hello-pangea/dnd (bifurcación de react-beautiful-dnd) | Necesitas arrastrar y soltar suave y animado con una API probada en batalla | Necesitas arrastrar entre iframes o ventanas externas |
Pointer Events (onPointerDown/Move/Up) | Necesitas comportamiento de arrastrar personalizado no limitado por restricciones de HTML5 DnD | Se requiere soltar de archivos nativo o arrastrar entre ventanas |
<input type="file"> | Solo necesitas selección de archivos, no una zona de soltar visual | La UX de arrastrar y soltar es un requisito central |
CSS touch-action + pointer events | Necesitas interacciones de arrastrar amigables con dispositivos móviles | DnD nativo solo para escritorio es suficiente |
Debes llamar a e.preventDefault() en el manejador onDragOver en el destino de soltar. El comportamiento predeterminado del navegador es rechazar soltadas. Sin onDragOver={(e) => e.preventDefault()}, el evento onDrop nunca se ejecutará.
dragstart se dispara en el elemento arrastrado cuando el usuario comienza a arrastrardrag se dispara continuamente en el elemento arrastrado mientras se arrastradragenter / dragover se disparan en destinos de soltar mientras el elemento arrastrado se mueve sobre ellosdrop se dispara en el destino de soltar cuando el usuario sueltadragend se dispara en el elemento arrastrado después de que la operación terminaAgrega el atributo draggable al elemento: <div draggable>Arrastrame</div>. Luego adjunta un manejador onDragStart para establecer los datos de arrastrar a través de e.dataTransfer.setData().
Mover desde una zona de soltar padre a un hijo dispara dragleave en el padre seguido de dragenter en el hijo, causando parpadeo visual. Soluciona esto usando un contador (incrementa en dragenter, decrementa en dragleave, reinicia en drop) o verificando e.currentTarget.contains(e.relatedTarget as Node).
Por seguridad, los navegadores solo permiten leer datos de arrastrar en eventos dragstart y drop. Durante dragover y dragenter, solo dataTransfer.types está disponible. Usa dataTransfer.types.includes("application/json") para verificar disponibilidad de datos durante dragover.
// En onDragStart
e.dataTransfer.setData(
"application/json",
JSON.stringify({ sourceColumn: "todo", taskId: "123" })
);
// En onDrop
const data = JSON.parse(e.dataTransfer.getData("application/json"));Firefox requiere al menos una llamada a e.dataTransfer.setData() en onDragStart o no iniciará el arrastrar. Siempre llama a e.dataTransfer.setData("text/plain", "") como mínimo en tu manejador onDragStart.
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 se configura en el origen de arrastrar en onDragStart para declarar operaciones permitidas ("move", "copy", "link", "all")dropEffect se configura en el destino de soltar en onDragOver para indicar la operación actual y controlar el icono del cursorEl atributo draggable intercepta gestos de clic-arrastrar que normalmente seleccionarían texto. Soluciona esto solo configurando draggable en un elemento de asa de arrastrar específico en lugar de toda el área de contenido.
No. La mayoría de navegadores móviles (iOS Safari, Chrome Android) tienen soporte pobre o nulo para arrastrar y soltar nativo. Usa eventos pointer/touch o una biblioteca como dnd-kit o @hello-pangea/dnd para interacciones de arrastrar amigables con dispositivos móviles.
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.files; // FileList
e.dataTransfer.types; // readonly string[]
};
// Ayudante de carga tipada
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 es React.DragEvent<HTMLLIElement>
e.currentTarget; // HTMLLIElement
e.dataTransfer; // DataTransfer
};Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥