Arrastrar y soltar en JavaScript (Drag and Drop)
En esta lección
Arrastrar y soltar (drag and drop) es una forma muy natural de mover cosas en una interfaz: tarjetas de un tablero, productos a un carrito, fotos a una zona de subida. HTML5 trae una API de arrastrar y soltar que el navegador gestiona casi sola: tú solo dices qué se puede arrastrar, dónde se puede soltar y qué pasa al soltar. En esta lección verás sus eventos y el objeto dataTransfer, construirás un tablero tipo kanban, aprenderás a recibir archivos desde el escritorio y verás qué hacer en móviles, donde esta API falla.
Antes conviene que domines los eventos y cómo modificar el DOM.
Las tres piezas
Toda operación de arrastrar y soltar tiene:
- Un origen: el elemento que se arrastra. Hay que marcarlo con el atributo
draggable="true". - Un destino: la zona donde se puede soltar. Por defecto, ningún elemento acepta que le suelten cosas; tienes que permitirlo tú.
- Los datos que viajan del origen al destino, en el objeto
dataTransferdel evento.
Los enlaces, las imágenes y el texto seleccionado ya se pueden arrastrar sin hacer nada. Cualquier otro elemento necesita draggable="true" (no basta con draggable a secas: el atributo necesita el valor).
<article class="tarjeta" draggable="true" id="t1">Comprar cuerda</article>
<section class="columna">Hecho</section>
Los eventos
Durante un arrastre se disparan eventos en el origen y en los posibles destinos:
| Evento | Dónde | Cuándo |
|---|---|---|
dragstart | Origen | Al empezar a arrastrar |
drag | Origen | Continuamente mientras se arrastra |
dragend | Origen | Al terminar, se haya soltado bien o no |
dragenter | Destino | Al entrar en un elemento |
dragover | Destino | Continuamente mientras está encima (varias veces por segundo) |
dragleave | Destino | Al salir de un elemento |
drop | Destino | Al soltar, solo si el destino lo permite |
Los imprescindibles son tres: dragstart para preparar los datos, dragover para permitir soltar y drop para hacer algo con lo que llega.
Un primer ejemplo: mover una ficha
const ficha = document.querySelector("#t1");
const zona = document.querySelector(".columna");
ficha.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", ficha.id);
});
zona.addEventListener("dragover", (e) => {
e.preventDefault(); // "aquí se puede soltar"
});
zona.addEventListener("drop", (e) => {
e.preventDefault();
const id = e.dataTransfer.getData("text/plain");
zona.append(document.getElementById(id));
});
El paso que todo el mundo olvida es el preventDefault() en dragover. Lo que hace el navegador por defecto durante un dragover es decir “aquí no se puede soltar”. Al cancelarlo, la zona pasa a aceptar el arrastre y el cursor cambia. Si no lo pones, el evento drop nunca se dispara.
El preventDefault() en drop evita otra acción por defecto: algunos navegadores, al soltar un enlace o un texto, intentan abrirlo o navegar a él.
Fíjate también en que append mueve el elemento: un nodo del DOM solo puede estar en un sitio, así que al añadirlo a la zona desaparece de donde estaba.
dataTransfer: los datos que viajan
El objeto e.dataTransfer es como un portapapeles temporal que dura lo que dura el arrastre.
setData(tipo, texto): guarda datos endragstart. El tipo suele ser"text/plain"; si quieres pasar varios datos, usa un tipo propio o guarda un JSON conJSON.stringify.getData(tipo): los lee endrop.effectAllowed(en el origen) ydropEffect(en el destino): indican la operación,"move","copy"o"link", y cambian el icono del cursor.files: los archivos, si lo que se arrastra viene del escritorio.
tarjeta.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("application/json", JSON.stringify({ id: tarjeta.id, origen: "pendiente" }));
e.dataTransfer.effectAllowed = "move";
});
columna.addEventListener("drop", (e) => {
e.preventDefault();
const datos = JSON.parse(e.dataTransfer.getData("application/json"));
console.log(datos.id, "viene de", datos.origen);
});
Nota: por seguridad,
getDatasolo devuelve los datos en el eventodrop. Endragoverodragenterdevuelve una cadena vacía, para que una página no pueda espiar lo que arrastras por encima de ella. Si necesitas saber qué se arrastra antes de soltar, guárdalo en una variable endragstart.
¿Por qué pasar el id en vez del elemento? Porque dataTransfer solo guarda texto. Además, así el mismo código funciona si arrastras entre dos ventanas (aunque en ese caso el getElementById no encontraría nada en la otra página: compruébalo siempre).
Dar pistas visuales
Un buen arrastre se ve: el elemento que se mueve se atenúa y la zona sobre la que estás se resalta.
document.addEventListener("dragstart", (e) => {
e.target.classList.add("arrastrando");
});
document.addEventListener("dragend", (e) => {
e.target.classList.remove("arrastrando");
});
zona.addEventListener("dragover", (e) => {
e.preventDefault();
zona.classList.add("encima");
});
zona.addEventListener("dragleave", (e) => {
// solo si de verdad salimos de la zona, no si pasamos a un hijo
if (!zona.contains(e.relatedTarget)) zona.classList.remove("encima");
});
zona.addEventListener("drop", () => zona.classList.remove("encima"));
El truco de relatedTarget es importante. Al pasar de la columna a una tarjeta que está dentro, el navegador dispara dragleave en la columna, igual que ocurre con mouseout (lo viste en eventos). e.relatedTarget es el elemento al que se ha pasado; si está dentro de la zona, no hemos salido de verdad.
Recuerda quitar la clase también en drop y en dragend, porque si sueltas, dragleave no siempre se dispara.
Proyecto: un tablero kanban
Un kanban es un tablero con columnas (pendiente, haciendo, hecho) y tarjetas que se mueven entre ellas. Es el ejemplo de “Pruébalo” de esta lección. Las ideas clave del código:
- Delegación de eventos: en lugar de añadir un
dragstarta cada tarjeta, se escucha endocumenty se comprueba cone.target.matches(".tarjeta"). Así funciona también con tarjetas que crees más tarde. - Cada columna escucha
dragover(conpreventDefault),dragleaveydrop. - En
dropse recupera la tarjeta por suidy se añade a la columna. - La columna guarda su estado en un atributo
data-estado, que se lee concolumna.dataset.estado.
Soltar en una posición concreta
Con append, la tarjeta siempre acaba al final de la columna. Para soltarla entre dos tarjetas, busca la primera tarjeta cuyo centro esté por debajo del ratón y colócala antes:
function tarjetaSiguiente(columna, y) {
const tarjetas = [...columna.querySelectorAll(".tarjeta:not(.arrastrando)")];
return tarjetas.find((t) => {
const caja = t.getBoundingClientRect();
return y < caja.top + caja.height / 2;
});
}
columna.addEventListener("drop", (e) => {
e.preventDefault();
const tarjeta = document.getElementById(e.dataTransfer.getData("text/plain"));
const siguiente = tarjetaSiguiente(columna, e.clientY);
if (siguiente) {
columna.insertBefore(tarjeta, siguiente);
} else {
columna.append(tarjeta);
}
});
getBoundingClientRect() devuelve la posición y el tamaño de un elemento en la pantalla, y e.clientY es la altura del ratón. Comparándolos sabes si estás en la mitad de arriba o en la de abajo de cada tarjeta.
Guardar el tablero
Si recargas la página, las tarjetas vuelven a su sitio original. Para conservar el orden, guarda en drop qué tarjetas hay en cada columna con localStorage:
function guardar() {
const datos = {};
for (const col of document.querySelectorAll(".columna")) {
datos[col.dataset.estado] = [...col.querySelectorAll(".tarjeta")].map((t) => t.id);
}
localStorage.setItem("kanban", JSON.stringify(datos));
}
Soltar archivos desde el escritorio
La misma API sirve para recibir archivos que el usuario arrastra desde su ordenador. Llegan en e.dataTransfer.files, una lista de objetos File con name, size (en bytes) y type (por ejemplo "image/png").
<div id="subida">Suelta aquí una foto</div>
<img id="vista" alt="Vista previa de la foto">
const subida = document.querySelector("#subida");
const vista = document.querySelector("#vista");
subida.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
subida.addEventListener("drop", (e) => {
e.preventDefault();
const archivo = e.dataTransfer.files[0];
if (!archivo || !archivo.type.startsWith("image/")) {
subida.textContent = "Eso no es una imagen";
return;
}
const lector = new FileReader();
lector.addEventListener("load", () => {
vista.src = lector.result; // la imagen como "data URL"
});
lector.readAsDataURL(archivo);
subida.textContent = `${archivo.name} (${Math.round(archivo.size / 1024)} KB)`;
});
FileReader lee el contenido de un archivo en el navegador, sin subirlo a ningún servidor. Es asíncrono: le pides que lea y te avisa con el evento load. readAsDataURL convierte el archivo en una data URL, un texto que empieza por data:image/png;base64,... y que puedes poner directamente como src de una imagen. Para leer archivos de texto, usa readAsText.
Una alternativa más ligera para previsualizar es URL.createObjectURL(archivo), que crea una dirección temporal sin copiar el archivo a memoria como texto; libérala con URL.revokeObjectURL cuando ya no la necesites.
Cuidado: si el usuario suelta el archivo fuera de tu zona, el navegador lo abre y la persona pierde tu página. Evítalo cancelando el comportamiento por defecto en toda la ventana:
window.addEventListener("dragover", (e) => e.preventDefault());y lo mismo con"drop".
Ofrece siempre también un <input type="file"> normal. No todo el mundo sabe que se puede arrastrar, y con teclado o en el móvil es la única forma. Puedes leer sus archivos igual, con input.files. Para enviar el archivo al servidor, se usa FormData con fetch.
Accesibilidad: no dependas solo del ratón
Arrastrar exige un ratón o un dedo y buena precisión. Una persona que navega con teclado o con un lector de pantalla no puede hacerlo. Por eso, toda acción de arrastrar debe tener una alternativa: botones “Mover a la derecha”, un <select> con la columna de destino o atajos de teclado.
// Alternativa con teclado: flechas para cambiar de columna
document.addEventListener("keydown", (e) => {
const tarjeta = e.target.closest(".tarjeta");
if (!tarjeta) return;
const columnas = [...document.querySelectorAll(".columna")];
const actual = columnas.indexOf(tarjeta.parentElement);
const destino = e.key === "ArrowRight" ? actual + 1 : e.key === "ArrowLeft" ? actual - 1 : -1;
if (columnas[destino]) {
columnas[destino].append(tarjeta);
tarjeta.focus();
}
});
Para que una tarjeta pueda recibir el foco con el tabulador, ponle tabindex="0".
Arrastrar en el móvil: Pointer Events
La API Drag and Drop nació pensando en el ratón. En pantallas táctiles el soporte es irregular: algunos navegadores móviles la activan con una pulsación larga y otros no, y el comportamiento cambia entre versiones. Si tu interfaz debe funcionar bien con el dedo, la opción fiable es programar el arrastre tú con Pointer Events, que unifican ratón, dedo y lápiz:
| Evento | Cuándo |
|---|---|
pointerdown | Se pulsa (botón del ratón o dedo en la pantalla) |
pointermove | Se mueve |
pointerup | Se suelta |
pointercancel | El sistema interrumpe el gesto (por ejemplo, una llamada) |
const pieza = document.querySelector("#pieza");
let inicioX = 0;
let inicioY = 0;
pieza.addEventListener("pointerdown", (e) => {
pieza.setPointerCapture(e.pointerId); // seguir recibiendo eventos aunque el dedo salga de la pieza
inicioX = e.clientX;
inicioY = e.clientY;
});
pieza.addEventListener("pointermove", (e) => {
if (!pieza.hasPointerCapture(e.pointerId)) return; // no se está arrastrando
const dx = e.clientX - inicioX;
const dy = e.clientY - inicioY;
pieza.style.translate = `${dx}px ${dy}px`;
});
pieza.addEventListener("pointerup", (e) => {
pieza.style.translate = "";
pieza.hidden = true; // para "ver" lo que hay debajo
const debajo = document.elementFromPoint(e.clientX, e.clientY);
pieza.hidden = false;
const zona = debajo?.closest(".columna");
if (zona) zona.append(pieza);
});
#pieza {
touch-action: none; /* que el navegador no haga scroll al arrastrar con el dedo */
}
Las claves:
setPointerCapturehace que el elemento siga recibiendopointermoveypointerupaunque el puntero se salga de él al moverse rápido.touch-action: noneen CSS impide que el navegador use el gesto para hacer scroll o zoom.document.elementFromPoint(x, y)dice qué elemento hay en una posición de la pantalla; así sabes sobre qué zona se ha soltado. Se oculta la pieza un instante para que no se encuentre a sí misma.
Es más trabajo que la API nativa, pero funciona igual en todas partes. Para proyectos grandes hay bibliotecas como SortableJS que ya resuelven todo esto, incluido el móvil.
Errores frecuentes
- No poner
preventDefault()endragover: eldropno se dispara nunca. Es el fallo número uno. - Escribir
draggablesin valor: tiene que serdraggable="true". - Leer
getDataendragover: devuelve""por seguridad; solo funciona endrop. - Que el resaltado parpadee:
dragleavese dispara al pasar sobre los hijos. Compruebazona.contains(e.relatedTarget). - Olvidar el
dragover/dropde la ventana al soltar archivos: si fallas la zona, el navegador abre el archivo y abandona tu página. - Confiar en el tipo de archivo:
archivo.typelo deduce el navegador por la extensión. Para un aviso en pantalla vale, pero el servidor debe comprobarlo de nuevo. - Hacer solo la versión de arrastrar: añade siempre botones, teclado o un
<input type="file">como alternativa.
Resumen
| Pieza | Para qué |
|---|---|
draggable="true" | Hacer arrastrable un elemento |
dragstart + setData | Preparar los datos al empezar |
dragover + preventDefault() | Permitir soltar en una zona |
drop + getData | Recibir los datos y actuar |
dragleave / dragend | Quitar los resaltados |
dataTransfer.files | Archivos soltados desde el escritorio |
FileReader.readAsDataURL | Previsualizar una imagen sin subirla |
| Pointer Events | Arrastre fiable con ratón, dedo y lápiz |
- La API nativa es cómoda en escritorio; en móviles, usa Pointer Events o una biblioteca.
- Toda acción de arrastrar necesita una alternativa con teclado o botones.
En la siguiente lección verás cómo leer y validar los datos que escribe el usuario: formularios con JavaScript.
Pruébalo tú
Cambia el código y pulsa Ejecutar (o Ctrl + Enter).
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.