Eventos Arrastrar y Soltar
Implementa interacciones nativas de arrastrar y soltar HTML5 con el sistema de eventos sintéticos de React.
Referencia de eventos
| 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á.
Receta
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.
Ejemplo funcionando
// 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:
- Hacer elementos arrastrables con el atributo
draggable - Rastrear origen de arrastre y destino de soltar a través de refs (evita re-renderizados durante arrastrar)
- Configurar
effectAllowedydropEffectpara retroalimentación correcta del cursor - Reordenar elementos de array al soltar con splice
- Retroalimentación visual: reducir opacidad en el elemento arrastrado
Análisis profundo
Cómo funciona
- HTML5 Drag and Drop requiere configurar
draggable={true}en el elemento origen. - El ciclo de vida del arrastrar es:
dragstart->drag(repetido) ->dragenter/dragover(en destinos) ->dropodragend. - Un elemento se convierte en un destino de soltar válido solo cuando su manejador
onDragOverllama ae.preventDefault(). Sin esto, el navegador ignora el soltar. - Los datos se pasan entre origen de arrastrar y destino de soltar a través de
e.dataTransfer, que soporta múltiples tipos MIME simultáneamente. - Durante
dragoverydragenter, solo puedes leerdataTransfer.types-- los datos reales degetData()solo están disponibles endropydragstartpor razones de seguridad.
Variaciones
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>
);
}Notas de TypeScript
// 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"));
}Trampas
-
onDropnunca se dispara sine.preventDefault()enonDragOver-- El comportamiento predeterminado del navegador es rechazar soltadas. Debes llamar ae.preventDefault()enonDragOveren cada elemento que deba aceptar soltadas. Solución: Siempre agregaonDragOver={(e) => e.preventDefault()}a los destinos de soltar. -
dataTransfer.getData()devuelve una cadena vacía durantedragover-- Por razones de seguridad, los navegadores solo permiten leer datos de arrastrar en eventosdragstartydrop. Durantedragoverydragenter, solodataTransfer.typesestá disponible. Solución: UsadataTransfer.types.includes("application/json")para verificar qué datos están disponibles durantedragover, y lee los datos reales enonDrop. -
Firefox requiere
dataTransfer.setData()endragstart-- Firefox no iniciará una operación de arrastrar a menos que llames asetDatacon al menos un formato. Solución: Siempre llama ae.dataTransfer.setData("text/plain", "")como mínimo en tu manejadoronDragStart. -
onDragLeavese dispara al entrar en elementos hijo -- Mover el cursor de una zona de soltar a un elemento hijo dentro de ella disparadragleaveen el padre, seguido dedragenteren el hijo. Esto causa parpadeo al alternar estados de soltar visuales. Solución: Usa un contador (incrementa endragenter, decrementa endragleave, reinicia endrop) o verificae.currentTarget.contains(e.relatedTarget as Node). -
La imagen fantasma de arrastrar captura el elemento en el tiempo de
dragstart-- Cualquier cambio de estilo hecho duranteonDragStart(como agregar una clase) puede no aparecer en la imagen fantasma porque el navegador captura la instantánea inmediatamente. Solución: Usae.dataTransfer.setDragImage()para imágenes fantasma personalizadas, o aplica estilos a través derequestAnimationFramepara asegurar que surtan efecto antes de la captura. -
draggableen elementos con texto hace imposible seleccionar texto -- Configurardraggable={true}en un elemento previene que los usuarios seleccionen texto dentro de él con clic-arrastrar. Solución: Solo configuradraggableen asas de arrastrar específicas en lugar de toda el área de contenido, o alternadraggablebasado 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-kito@hello-pangea/dndque maneja dispositivos táctiles.
Alternativas
| 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 |
Preguntas frecuentes
¿Por qué onDrop nunca se dispara en mi elemento destino de soltar?
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á.
¿Cuál es el ciclo de vida del evento de arrastrar en orden?
dragstartse dispara en el elemento arrastrado cuando el usuario comienza a arrastrardragse dispara continuamente en el elemento arrastrado mientras se arrastradragenter/dragoverse disparan en destinos de soltar mientras el elemento arrastrado se mueve sobre ellosdropse dispara en el destino de soltar cuando el usuario sueltadragendse dispara en el elemento arrastrado después de que la operación termina
¿Cómo haces un elemento HTML arrastrables en React?
Agrega 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().
Trampa: ¿Por qué se dispara onDragLeave cuando el cursor se mueve sobre un elemento hijo dentro de la zona de soltar?
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 qué dataTransfer.getData() devuelve una cadena vacía durante dragover?
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.
¿Cómo pasas datos estructurados entre origen de arrastrar y destino de soltar?
// En onDragStart
e.dataTransfer.setData(
"application/json",
JSON.stringify({ sourceColumn: "todo", taskId: "123" })
);
// En onDrop
const data = JSON.parse(e.dataTransfer.getData("application/json"));Trampa: ¿Por qué el arrastrar no comienza en absoluto en Firefox?
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.
¿Cómo implementas una zona de soltar de archivos con validación de tipo y tamaño?
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
);
};¿Cuál es la diferencia entre effectAllowed y dropEffect?
effectAllowedse configura en el origen de arrastrar enonDragStartpara declarar operaciones permitidas ("move","copy","link","all")dropEffectse configura en el destino de soltar enonDragOverpara indicar la operación actual y controlar el icono del cursor
¿Por qué configurar draggable en un elemento previene la selección de texto dentro de él?
El 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.
¿Funciona HTML5 drag and drop en navegadores móviles?
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.
¿Cuál es el tipo correcto de TypeScript para un manejador de evento de arrastrar, y cómo tipas la carga?
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"));
}¿Cómo usas el tipo abreviado React.DragEventHandler en TypeScript?
const onDrag: React.DragEventHandler<HTMLLIElement> = (e) => {
// e es React.DragEvent<HTMLLIElement>
e.currentTarget; // HTMLLIElement
e.dataTransfer; // DataTransfer
};Relacionado
- Clipboard Events -- Copiar, cortar y pegar con DataTransfer
- Mouse Events -- Clic, pasar el ratón e interacciones de puntero
- Pointer Events -- Manejo unificado de entrada de ratón, tacto y lápiz