Saltar al contenido
arrastrar-y-soltar.js · devschool

Arrastrar y soltar en JavaScript (Drag and Drop)

Lección 31 de 43 · 12 min de lectura · Actualizado el

En esta lección
  1. Las tres piezas
  2. Los eventos
  3. Un primer ejemplo: mover una ficha
  4. dataTransfer: los datos que viajan
  5. Dar pistas visuales
  6. Proyecto: un tablero kanban
  7. Soltar archivos desde el escritorio
  8. Accesibilidad: no dependas solo del ratón
  9. Arrastrar en el móvil: Pointer Events
  10. Errores frecuentes
  11. Resumen

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:

  1. Un origen: el elemento que se arrastra. Hay que marcarlo con el atributo draggable="true".
  2. Un destino: la zona donde se puede soltar. Por defecto, ningún elemento acepta que le suelten cosas; tienes que permitirlo tú.
  3. Los datos que viajan del origen al destino, en el objeto dataTransfer del 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:

EventoDóndeCuándo
dragstartOrigenAl empezar a arrastrar
dragOrigenContinuamente mientras se arrastra
dragendOrigenAl terminar, se haya soltado bien o no
dragenterDestinoAl entrar en un elemento
dragoverDestinoContinuamente mientras está encima (varias veces por segundo)
dragleaveDestinoAl salir de un elemento
dropDestinoAl 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 en dragstart. El tipo suele ser "text/plain"; si quieres pasar varios datos, usa un tipo propio o guarda un JSON con JSON.stringify.
  • getData(tipo): los lee en drop.
  • effectAllowed (en el origen) y dropEffect (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, getData solo devuelve los datos en el evento drop. En dragover o dragenter devuelve 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 en dragstart.

¿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:

  1. Delegación de eventos: en lugar de añadir un dragstart a cada tarjeta, se escucha en document y se comprueba con e.target.matches(".tarjeta"). Así funciona también con tarjetas que crees más tarde.
  2. Cada columna escucha dragover (con preventDefault), dragleave y drop.
  3. En drop se recupera la tarjeta por su id y se añade a la columna.
  4. La columna guarda su estado en un atributo data-estado, que se lee con columna.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:

EventoCuándo
pointerdownSe pulsa (botón del ratón o dedo en la pantalla)
pointermoveSe mueve
pointerupSe suelta
pointercancelEl 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:

  • setPointerCapture hace que el elemento siga recibiendo pointermove y pointerup aunque el puntero se salga de él al moverse rápido.
  • touch-action: none en 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() en dragover: el drop no se dispara nunca. Es el fallo número uno.
  • Escribir draggable sin valor: tiene que ser draggable="true".
  • Leer getData en dragover: devuelve "" por seguridad; solo funciona en drop.
  • Que el resaltado parpadee: dragleave se dispara al pasar sobre los hijos. Comprueba zona.contains(e.relatedTarget).
  • Olvidar el dragover/drop de 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.type lo 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

PiezaPara qué
draggable="true"Hacer arrastrable un elemento
dragstart + setDataPreparar los datos al empezar
dragover + preventDefault()Permitir soltar en una zona
drop + getDataRecibir los datos y actuar
dragleave / dragendQuitar los resaltados
dataTransfer.filesArchivos soltados desde el escritorio
FileReader.readAsDataURLPrevisualizar una imagen sin subirla
Pointer EventsArrastre 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).

arrastrar-y-soltar.html
Resultado

Pon a prueba lo que has aprendido

[JavaScript] Has puesto draggable="true" y un listener de drop, pero drop nunca se dispara. ¿Qué falta?

[JavaScript] ¿Cuándo devuelve datos dataTransfer.getData()?

[JavaScript] Un usuario suelta una imagen del escritorio fuera de tu zona de soltar y la página desaparece. ¿Por qué?

[JavaScript] ¿Qué API conviene usar para arrastrar de forma fiable también con el dedo en un móvil?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.