Drag and Drop

Avanzado
HTML
HTML
Actualizado: 01/05/2025

¡Desbloquea el curso de HTML completo!

IA
Ejercicios
Certificado
Entrar

Mira la lección en vídeo

Accede al vídeo completo de esta lección y a más contenido exclusivo con el Plan Plus.

Desbloquear Plan Plus

API nativa de Drag and Drop en HTML5

HTML5 introdujo una API nativa para implementar funcionalidades de arrastrar y soltar (drag and drop) en aplicaciones web sin necesidad de bibliotecas externas. Esta característica permite a los usuarios mover elementos en la página de forma intuitiva, similar a como lo harían en aplicaciones de escritorio.

La API de Drag and Drop de HTML5 permite que cualquier elemento de la página pueda convertirse en un elemento arrastrable o en una zona de destino donde soltar elementos. Esto abre un abanico de posibilidades para crear interfaces interactivas como:

  • Reordenación de listas
  • Movimiento de elementos entre contenedores
  • Cargas de archivos mediante arrastre
  • Interfaces tipo kanban o tableros de tareas

Fundamentos de la API

La API de Drag and Drop se basa en un modelo de eventos que se activan durante las diferentes fases del proceso de arrastrar y soltar. Para implementar esta funcionalidad necesitamos:

  1. Hacer que un elemento sea arrastrable
  2. Definir qué ocurre durante el arrastre
  3. Especificar dónde se pueden soltar los elementos
  4. Manejar la acción de soltar

Veamos un ejemplo básico de cómo hacer que un elemento sea arrastrable:

<div id="draggable" draggable="true">
  Arrástrame
</div>

El atributo draggable="true" es el punto de partida para cualquier implementación de drag and drop. Este atributo puede aplicarse a casi cualquier elemento HTML, aunque algunos elementos como las imágenes y los enlaces son arrastrables por defecto.

Transferencia de datos

Un concepto fundamental en la API de Drag and Drop es el objeto DataTransfer, que actúa como un contenedor para los datos que se transfieren durante la operación. Este objeto permite:

  • Especificar qué datos se están arrastrando
  • Definir el tipo de operación (copiar, mover, enlazar)
  • Establecer una imagen personalizada durante el arrastre

Para utilizar este objeto, necesitamos acceder a la propiedad dataTransfer del evento de arrastre:

<div id="source" draggable="true">Elemento arrastrable</div>

<script>
  const source = document.getElementById('source');
  
  source.addEventListener('dragstart', function(event) {
    // Guardamos datos en el objeto dataTransfer
    event.dataTransfer.setData('text/plain', 'Este elemento fue arrastrado');
    
    // Definimos el efecto visual permitido
    event.dataTransfer.effectAllowed = 'move';
  });
</script>

En este ejemplo, cuando comienza el arrastre:

  1. Almacenamos datos en formato texto plano
  2. Especificamos que la operación será de tipo "mover"

Zonas de destino (Drop Zones)

Para que un elemento pueda recibir elementos arrastrados, debemos configurarlo como una zona de destino. Esto implica prevenir el comportamiento predeterminado de los navegadores mediante los eventos dragover y drop:

<div id="source" draggable="true">Arrástrame</div>
<div id="target" class="dropzone">Suéltame aquí</div>

<script>
  const target = document.getElementById('target');
  
  // Prevenir el comportamiento predeterminado para permitir soltar
  target.addEventListener('dragover', function(event) {
    event.preventDefault();
    // Cambiamos el cursor para indicar que es una zona válida
    event.dataTransfer.dropEffect = 'move';
  });
  
  // Manejar el evento de soltar
  target.addEventListener('drop', function(event) {
    event.preventDefault();
    // Recuperamos los datos transferidos
    const data = event.dataTransfer.getData('text/plain');
    console.log('Datos recibidos:', data);
    
    // Aquí podríamos actualizar la interfaz
    this.textContent = 'Elemento recibido!';
  });
</script>

Es crucial llamar a event.preventDefault() en los eventos dragover y drop, ya que el comportamiento predeterminado de los navegadores es rechazar las operaciones de soltar.

Ejemplo completo

Veamos un ejemplo completo de una lista con elementos que pueden reordenarse mediante drag and drop:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Drag and Drop Example</title>
  <style>
    .draggable-item {
      padding: 10px;
      margin: 5px;
      background-color: #f0f0f0;
      border: 1px solid #ccc;
      cursor: move;
    }
    
    .drop-zone {
      border: 2px dashed #aaa;
      min-height: 50px;
      padding: 10px;
      margin: 10px 0;
    }
    
    .dragging {
      opacity: 0.5;
    }
  </style>
</head>
<body>
  <h2>Lista reordenable</h2>
  <div id="container" class="drop-zone">
    <div class="draggable-item" draggable="true">Elemento 1</div>
    <div class="draggable-item" draggable="true">Elemento 2</div>
    <div class="draggable-item" draggable="true">Elemento 3</div>
    <div class="draggable-item" draggable="true">Elemento 4</div>
  </div>

  <script>
    // Seleccionamos todos los elementos arrastrables
    const items = document.querySelectorAll('.draggable-item');
    const container = document.getElementById('container');
    
    // Configuramos cada elemento como arrastrable
    items.forEach(item => {
      // Al comenzar a arrastrar
      item.addEventListener('dragstart', function(e) {
        // Añadimos una clase para cambiar la apariencia
        this.classList.add('dragging');
        // Guardamos el ID del elemento
        e.dataTransfer.setData('text/plain', this.textContent);
      });
      
      // Al terminar de arrastrar
      item.addEventListener('dragend', function() {
        this.classList.remove('dragging');
      });
    });
    
    // Configuramos el contenedor como zona de destino
    container.addEventListener('dragover', function(e) {
      e.preventDefault();
      // Encontramos el elemento sobre el que estamos arrastrando
      const afterElement = getDragAfterElement(container, e.clientY);
      const draggable = document.querySelector('.dragging');
      
      if (afterElement == null) {
        container.appendChild(draggable);
      } else {
        container.insertBefore(draggable, afterElement);
      }
    });
    
    // Función para determinar la posición donde insertar el elemento
    function getDragAfterElement(container, y) {
      // Obtenemos todos los elementos arrastrables que no están siendo arrastrados
      const draggableElements = [...container.querySelectorAll('.draggable-item:not(.dragging)')];
      
      // Encontramos el elemento más cercano a la posición del cursor
      return draggableElements.reduce((closest, child) => {
        const box = child.getBoundingClientRect();
        const offset = y - box.top - box.height / 2;
        
        // Si estamos por encima del elemento y más cerca que el anterior "más cercano"
        if (offset < 0 && offset > closest.offset) {
          return { offset: offset, element: child };
        } else {
          return closest;
        }
      }, { offset: Number.NEGATIVE_INFINITY }).element;
    }
  </script>
</body>
</html>

En este ejemplo:

  1. Creamos una lista de elementos con el atributo draggable="true"
  2. Aplicamos estilos para mejorar la experiencia visual
  3. Implementamos la lógica para reordenar los elementos dentro del contenedor
  4. Utilizamos una función auxiliar para determinar la posición donde insertar el elemento arrastrado

Compatibilidad con navegadores

La API de Drag and Drop de HTML5 tiene amplio soporte en navegadores modernos, pero existen algunas diferencias en la implementación entre ellos. Los principales aspectos a considerar son:

  • Firefox requiere que se establezca explícitamente algún dato en el objeto dataTransfer durante el evento dragstart
  • Safari tiene algunas limitaciones con elementos anidados
  • Los navegadores móviles tienen soporte limitado para esta API

Para aplicaciones que necesiten funcionar en dispositivos móviles, es recomendable complementar esta API con bibliotecas o implementaciones personalizadas que utilicen eventos táctiles.

Limitaciones de la API nativa

Aunque la API de Drag and Drop de HTML5 es potente, tiene algunas limitaciones:

  • No proporciona animaciones fluidas por defecto
  • El soporte en dispositivos móviles es inconsistente
  • La personalización visual durante el arrastre es limitada
  • No incluye funcionalidades avanzadas como arrastrar entre frames o ventanas

Para casos de uso más complejos, puede ser necesario complementar la API nativa con bibliotecas JavaScript especializadas o implementaciones personalizadas.

La API nativa de Drag and Drop es una herramienta fundamental para crear interfaces interactivas en aplicaciones web modernas, proporcionando una base sólida sobre la cual construir experiencias de usuario intuitivas y eficientes.

Atributos draggable y eventos asociados

El atributo draggable es el punto de partida para implementar funcionalidades de arrastrar y soltar en HTML5. Este atributo permite especificar si un elemento puede ser arrastrado por el usuario, convirtiendo elementos estáticos en componentes interactivos de la interfaz.

Para hacer que un elemento sea arrastrable, simplemente necesitamos añadir el atributo draggable="true" al elemento HTML:

<div id="element" draggable="true">Este elemento puede arrastrarse</div>

Valores del atributo draggable

El atributo draggable puede tener tres valores posibles:

  • true - El elemento puede ser arrastrado
  • false - El elemento no puede ser arrastrado
  • auto - Se aplica el comportamiento predeterminado del navegador (valor por defecto)

Es importante mencionar que algunos elementos HTML son arrastrables por defecto, como las imágenes y los enlaces. Para estos elementos, el valor predeterminado es auto, lo que permite arrastrarlos sin configuración adicional:

<!-- Las imágenes son arrastrables por defecto -->
<img src="imagen.jpg" alt="Imagen arrastrable">

<!-- Los enlaces también son arrastrables por defecto -->
<a href="https://ejemplo.com">Enlace arrastrable</a>

Si queremos deshabilitar esta funcionalidad en elementos que son arrastrables por defecto, podemos establecer explícitamente draggable="false":

<!-- Esta imagen no podrá ser arrastrada -->
<img src="imagen.jpg" alt="Imagen no arrastrable" draggable="false">

Eventos del ciclo de arrastre

Para controlar el comportamiento durante las operaciones de arrastre, HTML5 proporciona un conjunto de eventos específicos que se activan en diferentes momentos del proceso. Estos eventos se dividen en dos categorías principales:

Eventos en el elemento arrastrable

Estos eventos se disparan en el elemento que está siendo arrastrado:

  • dragstart: Se activa cuando comienza la operación de arrastre
  • drag: Se activa continuamente mientras el elemento está siendo arrastrado
  • dragend: Se activa cuando finaliza la operación de arrastre

Eventos en la zona de destino

Estos eventos se disparan en los elementos que pueden recibir elementos arrastrados:

  • dragenter: Se activa cuando un elemento arrastrable entra en la zona de destino
  • dragover: Se activa continuamente mientras un elemento arrastrable está sobre la zona de destino
  • dragleave: Se activa cuando un elemento arrastrable sale de la zona de destino
  • drop: Se activa cuando se suelta un elemento arrastrable en la zona de destino

Implementación básica con eventos

Veamos cómo implementar una funcionalidad básica de arrastrar y soltar utilizando estos eventos:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Drag Events Example</title>
  <style>
    #draggable {
      width: 100px;
      height: 100px;
      background-color: #3498db;
      color: white;
      text-align: center;
      line-height: 100px;
      cursor: move;
    }
    
    #dropzone {
      width: 200px;
      height: 200px;
      border: 2px dashed #2c3e50;
      margin-top: 20px;
      padding: 10px;
      text-align: center;
    }
    
    .dragging {
      opacity: 0.5;
    }
    
    .highlight {
      background-color: #ecf0f1;
    }
  </style>
</head>
<body>
  <div id="draggable" draggable="true">Arrástrame</div>
  <div id="dropzone">Zona de destino</div>
  
  <script>
    const draggable = document.getElementById('draggable');
    const dropzone = document.getElementById('dropzone');
    
    // Eventos en el elemento arrastrable
    draggable.addEventListener('dragstart', function(event) {
      // Añadimos una clase para cambiar la apariencia
      this.classList.add('dragging');
      
      // Guardamos el ID del elemento en el dataTransfer
      event.dataTransfer.setData('text/plain', this.id);
    });
    
    draggable.addEventListener('dragend', function() {
      // Restauramos la apariencia original
      this.classList.remove('dragging');
    });
    
    // Eventos en la zona de destino
    dropzone.addEventListener('dragenter', function(event) {
      // Prevenimos el comportamiento predeterminado
      event.preventDefault();
      // Añadimos una clase para resaltar la zona
      this.classList.add('highlight');
    });
    
    dropzone.addEventListener('dragover', function(event) {
      // Este preventDefault es CRUCIAL para permitir el drop
      event.preventDefault();
    });
    
    dropzone.addEventListener('dragleave', function() {
      // Quitamos el resaltado cuando el elemento sale
      this.classList.remove('highlight');
    });
    
    dropzone.addEventListener('drop', function(event) {
      // Prevenimos la acción predeterminada (como abrir como enlace)
      event.preventDefault();
      
      // Quitamos la clase de resaltado
      this.classList.remove('highlight');
      
      // Obtenemos el ID del elemento arrastrado
      const id = event.dataTransfer.getData('text/plain');
      const draggedElement = document.getElementById(id);
      
      // Añadimos el elemento a la zona de destino
      this.appendChild(draggedElement);
    });
  </script>
</body>
</html>

En este ejemplo:

  1. Creamos un elemento arrastrable con draggable="true"
  2. Definimos una zona donde se puede soltar el elemento
  3. Implementamos los manejadores de eventos para controlar el comportamiento durante el arrastre
  4. Utilizamos clases CSS para proporcionar retroalimentación visual al usuario

Personalización del cursor durante el arrastre

Podemos personalizar la apariencia del cursor durante la operación de arrastre utilizando la propiedad dropEffect del objeto dataTransfer:

draggable.addEventListener('dragstart', function(event) {
  // Especificamos qué efectos están permitidos
  event.dataTransfer.effectAllowed = 'move';
  
  // Guardamos datos
  event.dataTransfer.setData('text/plain', this.id);
});

dropzone.addEventListener('dragover', function(event) {
  event.preventDefault();
  // Establecemos el efecto visual (cambia el cursor)
  event.dataTransfer.dropEffect = 'move';
});

Los valores posibles para dropEffect son:

  • copy - Indica que se creará una copia del elemento arrastrado
  • move - Indica que el elemento arrastrado se moverá a la nueva ubicación
  • link - Indica que se creará un enlace a la ubicación original
  • none - No se permite soltar el elemento

Imagen personalizada durante el arrastre

También podemos personalizar la imagen que se muestra durante el arrastre utilizando el método setDragImage():

draggable.addEventListener('dragstart', function(event) {
  // Creamos una imagen personalizada
  const img = new Image();
  img.src = 'custom-drag-image.png';
  
  // Establecemos la imagen personalizada
  // Parámetros: imagen, offsetX, offsetY
  event.dataTransfer.setDragImage(img, 0, 0);
  
  // Guardamos datos
  event.dataTransfer.setData('text/plain', this.id);
});

Si no queremos usar una imagen externa, podemos crear un elemento HTML, renderizarlo y usarlo como imagen de arrastre:

draggable.addEventListener('dragstart', function(event) {
  // Creamos un elemento personalizado
  const dragIcon = document.createElement('div');
  dragIcon.textContent = 'Arrastrando...';
  dragIcon.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
  dragIcon.style.color = 'white';
  dragIcon.style.padding = '10px';
  dragIcon.style.borderRadius = '5px';
  
  // Añadimos el elemento al DOM (necesario para renderizarlo)
  document.body.appendChild(dragIcon);
  
  // Lo hacemos invisible
  dragIcon.style.position = 'absolute';
  dragIcon.style.top = '-1000px';
  
  // Establecemos el elemento como imagen de arrastre
  event.dataTransfer.setDragImage(dragIcon, 0, 0);
  
  // Guardamos datos
  event.dataTransfer.setData('text/plain', this.id);
  
  // Eliminamos el elemento después de un breve retraso
  setTimeout(() => {
    document.body.removeChild(dragIcon);
  }, 0);
});

Transferencia de datos complejos

Aunque en los ejemplos anteriores hemos transferido datos simples (como el ID de un elemento), el objeto dataTransfer permite transferir datos más complejos utilizando diferentes formatos:

draggable.addEventListener('dragstart', function(event) {
  // Transferir datos en formato texto
  event.dataTransfer.setData('text/plain', 'Texto simple');
  
  // Transferir datos en formato HTML
  event.dataTransfer.setData('text/html', '<p>Contenido <strong>HTML</strong></p>');
  
  // Transferir datos en formato JSON
  const data = {
    id: this.id,
    content: this.textContent,
    position: {
      x: this.offsetLeft,
      y: this.offsetTop
    }
  };
  
  event.dataTransfer.setData('application/json', JSON.stringify(data));
});

// En el evento drop, podemos recuperar los datos según el formato
dropzone.addEventListener('drop', function(event) {
  event.preventDefault();
  
  // Recuperar datos en formato JSON
  if (event.dataTransfer.types.includes('application/json')) {
    const jsonData = event.dataTransfer.getData('application/json');
    const data = JSON.parse(jsonData);
    console.log('Datos JSON:', data);
  }
  
  // Recuperar datos en formato HTML
  if (event.dataTransfer.types.includes('text/html')) {
    const htmlContent = event.dataTransfer.getData('text/html');
    console.log('Contenido HTML:', htmlContent);
  }
  
  // Recuperar datos en formato texto
  const textContent = event.dataTransfer.getData('text/plain');
  console.log('Texto plano:', textContent);
});

Ejemplo práctico: Lista de tareas con drag and drop

Veamos un ejemplo más completo de una lista de tareas donde podemos mover elementos entre dos columnas:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Task List with Drag and Drop</title>
  <style>
    .container {
      display: flex;
      gap: 20px;
    }
    
    .task-list {
      width: 250px;
      min-height: 300px;
      border: 1px solid #ccc;
      border-radius: 5px;
      padding: 10px;
    }
    
    .task-list h3 {
      text-align: center;
      margin-top: 0;
    }
    
    .task {
      background-color: #f9f9f9;
      padding: 10px;
      margin-bottom: 5px;
      border-radius: 3px;
      cursor: move;
      border-left: 3px solid #3498db;
    }
    
    .task.dragging {
      opacity: 0.5;
    }
    
    .task-list.highlight {
      background-color: #ecf0f1;
    }
  </style>
</head>
<body>
  <h2>Lista de tareas</h2>
  <div class="container">
    <div id="pending" class="task-list">
      <h3>Pendientes</h3>
      <div class="task" draggable="true">Diseñar página de inicio</div>
      <div class="task" draggable="true">Implementar API REST</div>
      <div class="task" draggable="true">Crear base de datos</div>
    </div>
    
    <div id="completed" class="task-list">
      <h3>Completadas</h3>
      <div class="task" draggable="true">Configurar entorno de desarrollo</div>
    </div>
  </div>
  
  <script>
    // Seleccionamos todos los elementos necesarios
    const tasks = document.querySelectorAll('.task');
    const lists = document.querySelectorAll('.task-list');
    
    // Configuramos cada tarea como arrastrable
    tasks.forEach(task => {
      task.addEventListener('dragstart', function(event) {
        // Añadimos clase para cambiar apariencia
        this.classList.add('dragging');
        
        // Guardamos el ID de la tarea y su lista original
        const data = {
          taskId: this.textContent,
          sourceListId: this.parentElement.id
        };
        
        event.dataTransfer.setData('application/json', JSON.stringify(data));
        event.dataTransfer.effectAllowed = 'move';
      });
      
      task.addEventListener('dragend', function() {
        // Restauramos apariencia
        this.classList.remove('dragging');
      });
    });
    
    // Configuramos cada lista como zona de destino
    lists.forEach(list => {
      list.addEventListener('dragenter', function(event) {
        event.preventDefault();
        // Solo resaltamos si no es un elemento hijo
        if (event.target.classList.contains('task-list')) {
          this.classList.add('highlight');
        }
      });
      
      list.addEventListener('dragover', function(event) {
        event.preventDefault();
        event.dataTransfer.dropEffect = 'move';
      });
      
      list.addEventListener('dragleave', function(event) {
        // Solo quitamos el resaltado si salimos de la lista
        // y no entramos en un hijo
        if (event.relatedTarget && !this.contains(event.relatedTarget)) {
          this.classList.remove('highlight');
        }
      });
      
      list.addEventListener('drop', function(event) {
        event.preventDefault();
        this.classList.remove('highlight');
        
        // Obtenemos los datos de la tarea
        const data = JSON.parse(event.dataTransfer.getData('application/json'));
        
        // Buscamos la tarea que se está arrastrando
        const draggedTask = document.querySelector('.dragging');
        
        // Si la tarea no existe en el DOM, la creamos
        if (!draggedTask) {
          const newTask = document.createElement('div');
          newTask.classList.add('task');
          newTask.setAttribute('draggable', 'true');
          newTask.textContent = data.taskId;
          
          // Añadimos los eventos a la nueva tarea
          newTask.addEventListener('dragstart', function(e) {
            this.classList.add('dragging');
            const taskData = {
              taskId: this.textContent,
              sourceListId: this.parentElement.id
            };
            e.dataTransfer.setData('application/json', JSON.stringify(taskData));
            e.dataTransfer.effectAllowed = 'move';
          });
          
          newTask.addEventListener('dragend', function() {
            this.classList.remove('dragging');
          });
          
          // Añadimos la tarea a la lista
          this.appendChild(newTask);
        } else {
          // Movemos la tarea a la nueva lista
          this.appendChild(draggedTask);
        }
      });
    });
  </script>
</body>
</html>

En este ejemplo:

  1. Creamos dos listas de tareas: "Pendientes" y "Completadas"
  2. Hacemos que cada tarea sea arrastrable con draggable="true"
  3. Implementamos los eventos necesarios para permitir mover tareas entre las listas
  4. Proporcionamos retroalimentación visual durante el proceso de arrastre

El atributo draggable y los eventos asociados forman la base de la API de Drag and Drop de HTML5, permitiendo crear interfaces interactivas y mejorando significativamente la experiencia de usuario en aplicaciones web modernas.

Eventos del ciclo de arrastrar y soltar

Guarda tu progreso

Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

Más de 25.000 desarrolladores ya confían en CertiDevs

El proceso de arrastrar y soltar en HTML5 sigue un ciclo de eventos bien definido que nos permite controlar cada fase de la interacción. Comprender estos eventos y su secuencia es fundamental para implementar funcionalidades de drag and drop que respondan adecuadamente a las acciones del usuario.

Secuencia de eventos

Cuando un usuario interactúa con elementos arrastrables, se desencadena una secuencia ordenada de eventos que podemos capturar para personalizar el comportamiento:

  1. dragstart → Se dispara cuando comienza la acción de arrastre
  2. drag → Se dispara repetidamente mientras se arrastra el elemento
  3. dragenter → Se dispara cuando el elemento arrastrado entra en una zona de destino
  4. dragover → Se dispara repetidamente mientras el elemento arrastrado está sobre una zona de destino
  5. dragleave → Se dispara cuando el elemento arrastrado sale de una zona de destino
  6. drop → Se dispara cuando el elemento es soltado en una zona de destino
  7. dragend → Se dispara cuando finaliza la operación de arrastre (tanto si se completó con éxito como si se canceló)

Esta secuencia nos permite implementar una retroalimentación visual durante todo el proceso y ejecutar la lógica necesaria en cada fase.

Visualización del flujo de eventos

Veamos un ejemplo que muestra cómo se activan estos eventos durante una operación de arrastre:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Drag and Drop Event Flow</title>
  <style>
    #draggable {
      width: 150px;
      height: 50px;
      background-color: #3498db;
      color: white;
      text-align: center;
      line-height: 50px;
      margin-bottom: 20px;
      cursor: move;
    }
    
    .dropzone {
      width: 250px;
      height: 150px;
      border: 2px dashed #ccc;
      margin: 10px 0;
      padding: 10px;
    }
    
    #log {
      margin-top: 20px;
      padding: 10px;
      border: 1px solid #eee;
      height: 200px;
      overflow-y: auto;
      font-family: monospace;
    }
    
    .event-log {
      margin: 5px 0;
      padding: 3px;
    }
    
    .dragstart { background-color: #f1c40f; }
    .drag { background-color: #e8f8f5; }
    .dragenter { background-color: #d5f5e3; }
    .dragover { background-color: #eafaf1; }
    .dragleave { background-color: #fdedec; }
    .drop { background-color: #d5f5e3; }
    .dragend { background-color: #ebdef0; }
  </style>
</head>
<body>
  <h2>Flujo de eventos de Drag and Drop</h2>
  
  <div id="draggable" draggable="true">Arrástrame</div>
  
  <div id="dropzone1" class="dropzone">Zona de destino 1</div>
  <div id="dropzone2" class="dropzone">Zona de destino 2</div>
  
  <div id="log">
    <div>Registro de eventos:</div>
  </div>
  
  <script>
    const draggable = document.getElementById('draggable');
    const dropzones = document.querySelectorAll('.dropzone');
    const log = document.getElementById('log');
    
    // Función para registrar eventos
    function logEvent(eventName, targetId) {
      const entry = document.createElement('div');
      entry.classList.add('event-log', eventName);
      
      const time = new Date().toLocaleTimeString();
      entry.textContent = `${time} - ${eventName} en ${targetId}`;
      
      log.appendChild(entry);
      log.scrollTop = log.scrollHeight;
    }
    
    // Eventos en el elemento arrastrable
    draggable.addEventListener('dragstart', function(event) {
      logEvent('dragstart', this.id);
      event.dataTransfer.setData('text/plain', this.id);
    });
    
    draggable.addEventListener('drag', function() {
      logEvent('drag', this.id);
    });
    
    draggable.addEventListener('dragend', function() {
      logEvent('dragend', this.id);
    });
    
    // Eventos en las zonas de destino
    dropzones.forEach(zone => {
      zone.addEventListener('dragenter', function(event) {
        event.preventDefault();
        logEvent('dragenter', this.id);
      });
      
      zone.addEventListener('dragover', function(event) {
        event.preventDefault();
        logEvent('dragover', this.id);
      });
      
      zone.addEventListener('dragleave', function(event) {
        logEvent('dragleave', this.id);
      });
      
      zone.addEventListener('drop', function(event) {
        event.preventDefault();
        logEvent('drop', this.id);
        
        const id = event.dataTransfer.getData('text/plain');
        const element = document.getElementById(id);
        this.appendChild(element);
      });
    });
  </script>
</body>
</html>

Este ejemplo muestra visualmente la secuencia de eventos que se activan durante una operación de arrastre y soltar. Observarás que:

  1. El evento drag se dispara continuamente mientras se mueve el elemento
  2. Los eventos dragover se activan repetidamente mientras el elemento está sobre una zona de destino
  3. El evento dragend siempre se dispara al final, independientemente de si el elemento fue soltado en una zona válida

Características específicas de cada evento

Cada evento del ciclo tiene propósitos específicos y características particulares que debemos conocer:

Evento dragstart

Este evento marca el inicio de la operación de arrastre y es el momento ideal para:

  • Configurar los datos que se transferirán
  • Establecer la imagen de arrastre personalizada
  • Definir los efectos permitidos
  • Aplicar estilos visuales al elemento que se está arrastrando
element.addEventListener('dragstart', function(event) {
  // Almacenar datos para transferir
  event.dataTransfer.setData('text/plain', this.id);
  
  // Definir los efectos permitidos (copy, move, link)
  event.dataTransfer.effectAllowed = 'move';
  
  // Añadir clase para cambiar apariencia
  this.classList.add('dragging');
});

Evento drag

Este evento se dispara continuamente mientras se arrastra el elemento. Es útil para:

  • Actualizar elementos de la interfaz basados en la posición actual
  • Implementar efectos visuales dinámicos

Sin embargo, es importante tener en cuenta que este evento se activa con alta frecuencia, por lo que las operaciones realizadas en su manejador deben ser ligeras para evitar problemas de rendimiento.

element.addEventListener('drag', function(event) {
  // Actualizar un contador o indicador
  document.getElementById('status').textContent = 'Arrastrando...';
  
  // Evitar operaciones pesadas aquí
});

Eventos dragenter y dragover

Estos eventos se activan cuando un elemento arrastrado entra y permanece sobre una potencial zona de destino:

  • dragenter se dispara una vez cuando el elemento entra en la zona
  • dragover se dispara repetidamente mientras el elemento permanece sobre la zona

El comportamiento predeterminado de los navegadores es rechazar las operaciones de soltar, por lo que es crucial llamar a event.preventDefault() en estos eventos para permitir que se active el evento drop:

dropzone.addEventListener('dragenter', function(event) {
  // Prevenir comportamiento predeterminado
  event.preventDefault();
  
  // Añadir clase para resaltar la zona
  this.classList.add('highlight');
});

dropzone.addEventListener('dragover', function(event) {
  // Este preventDefault es ESENCIAL para que funcione el drop
  event.preventDefault();
  
  // Definir el efecto visual (cambia el cursor)
  event.dataTransfer.dropEffect = 'move';
});

Evento dragleave

Este evento se activa cuando el elemento arrastrado sale de una zona de destino. Es el momento ideal para:

  • Eliminar estilos visuales aplicados durante dragenter
  • Cancelar cualquier preparación realizada para recibir el elemento
dropzone.addEventListener('dragleave', function(event) {
  // Quitar resaltado visual
  this.classList.remove('highlight');
});

Evento drop

Este evento se activa cuando el usuario suelta el elemento en una zona de destino válida. Aquí es donde implementamos la lógica principal:

  • Recuperar los datos transferidos
  • Realizar cambios en el DOM
  • Actualizar el estado de la aplicación
dropzone.addEventListener('drop', function(event) {
  // Prevenir comportamiento predeterminado (como abrir como enlace)
  event.preventDefault();
  
  // Quitar estilos visuales
  this.classList.remove('highlight');
  
  // Recuperar datos
  const id = event.dataTransfer.getData('text/plain');
  const element = document.getElementById(id);
  
  // Realizar la acción deseada (por ejemplo, mover el elemento)
  this.appendChild(element);
});

Evento dragend

Este evento se activa cuando finaliza la operación de arrastre, independientemente de si se completó con éxito (drop) o se canceló. Es útil para:

  • Limpiar estados temporales
  • Restaurar la apariencia original del elemento arrastrado
  • Realizar acciones finales basadas en el resultado de la operación
element.addEventListener('dragend', function(event) {
  // Quitar clase de arrastre
  this.classList.remove('dragging');
  
  // Verificar si la operación fue exitosa
  if (event.dataTransfer.dropEffect === 'none') {
    // La operación fue cancelada
    document.getElementById('status').textContent = 'Arrastre cancelado';
  } else {
    // La operación fue exitosa
    document.getElementById('status').textContent = 'Elemento movido';
  }
});

Propagación de eventos y burbujas

Los eventos de drag and drop siguen las reglas estándar de propagación de eventos en el DOM. Esto significa que:

  1. Los eventos se propagan desde el elemento objetivo hacia arriba en el árbol DOM
  2. Podemos usar event.stopPropagation() para detener esta propagación
  3. Podemos usar la delegación de eventos para manejar múltiples elementos arrastrables

Este comportamiento es especialmente relevante cuando trabajamos con elementos anidados:

<div id="outer-dropzone" class="dropzone">
  Zona externa
  <div id="inner-dropzone" class="dropzone">
    Zona interna
  </div>
</div>

En esta estructura, cuando arrastramos un elemento sobre la zona interna, los eventos dragenter y dragover se activarán tanto en la zona interna como en la externa (a través de la propagación).

Para manejar correctamente esta situación, podemos usar event.stopPropagation():

innerDropzone.addEventListener('dragover', function(event) {
  event.preventDefault();
  event.stopPropagation(); // Evita que el evento llegue a la zona externa
  
  // Lógica específica para la zona interna
});

Ejemplo práctico: Tablero Kanban simple

Veamos un ejemplo práctico que implementa un tablero Kanban simple con tres columnas, utilizando los eventos del ciclo de arrastrar y soltar:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Tablero Kanban Simple</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      margin: 20px;
    }
    
    .board {
      display: flex;
      gap: 20px;
    }
    
    .column {
      flex: 1;
      min-width: 250px;
      background-color: #f5f5f5;
      border-radius: 5px;
      padding: 10px;
    }
    
    .column-header {
      padding: 10px;
      background-color: #e0e0e0;
      border-radius: 3px;
      margin-bottom: 10px;
      text-align: center;
      font-weight: bold;
    }
    
    .card {
      background-color: white;
      padding: 10px;
      margin-bottom: 10px;
      border-radius: 3px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.12);
      cursor: move;
    }
    
    .card.dragging {
      opacity: 0.4;
    }
    
    .column.highlight {
      background-color: #e8f4fc;
    }
  </style>
</head>
<body>
  <h2>Tablero Kanban Simple</h2>
  
  <div class="board">
    <div id="todo" class="column">
      <div class="column-header">Por hacer</div>
      <div class="card" draggable="true">Diseñar página de inicio</div>
      <div class="card" draggable="true">Crear base de datos</div>
      <div class="card" draggable="true">Investigar API de pagos</div>
    </div>
    
    <div id="doing" class="column">
      <div class="column-header">En progreso</div>
      <div class="card" draggable="true">Implementar autenticación</div>
    </div>
    
    <div id="done" class="column">
      <div class="column-header">Completado</div>
      <div class="card" draggable="true">Configurar repositorio</div>
    </div>
  </div>
  
  <script>
    // Seleccionar todos los elementos necesarios
    const cards = document.querySelectorAll('.card');
    const columns = document.querySelectorAll('.column');
    
    // Configurar eventos para las tarjetas
    cards.forEach(card => {
      // Evento dragstart - Inicio del arrastre
      card.addEventListener('dragstart', function(event) {
        // Marcar visualmente la tarjeta
        this.classList.add('dragging');
        
        // Guardar información sobre la tarjeta y su columna original
        const data = {
          cardId: this.textContent,
          sourceColumnId: this.parentElement.id
        };
        
        event.dataTransfer.setData('application/json', JSON.stringify(data));
        event.dataTransfer.effectAllowed = 'move';
      });
      
      // Evento drag - Durante el arrastre
      card.addEventListener('drag', function() {
        // Este evento se dispara continuamente
        // Evitamos operaciones pesadas aquí
      });
      
      // Evento dragend - Fin del arrastre
      card.addEventListener('dragend', function() {
        // Restaurar apariencia
        this.classList.remove('dragging');
      });
    });
    
    // Configurar eventos para las columnas
    columns.forEach(column => {
      // Evento dragenter - Entrada en zona de destino
      column.addEventListener('dragenter', function(event) {
        // Solo si el objetivo directo es la columna (no una tarjeta dentro)
        if (event.target.classList.contains('column')) {
          event.preventDefault();
          this.classList.add('highlight');
        }
      });
      
      // Evento dragover - Permanencia sobre zona de destino
      column.addEventListener('dragover', function(event) {
        event.preventDefault(); // Crucial para permitir el drop
        event.dataTransfer.dropEffect = 'move';
      });
      
      // Evento dragleave - Salida de zona de destino
      column.addEventListener('dragleave', function(event) {
        // Solo quitamos el resaltado si salimos realmente de la columna
        // y no entramos en un hijo
        if (event.relatedTarget && !this.contains(event.relatedTarget)) {
          this.classList.remove('highlight');
        }
      });
      
      // Evento drop - Soltar en zona de destino
      column.addEventListener('drop', function(event) {
        event.preventDefault();
        this.classList.remove('highlight');
        
        // Recuperar datos de la tarjeta
        const data = JSON.parse(event.dataTransfer.getData('application/json'));
        
        // Encontrar la tarjeta que se está arrastrando
        const draggedCard = document.querySelector('.dragging');
        
        // Insertar la tarjeta en la columna, después del encabezado
        const header = this.querySelector('.column-header');
        this.insertBefore(draggedCard, header.nextSibling);
        
        // Aquí podríamos enviar una actualización al servidor
        console.log(`Tarjeta "${data.cardId}" movida de "${data.sourceColumnId}" a "${this.id}"`);
      });
    });
  </script>
</body>
</html>

En este ejemplo:

  1. Creamos un tablero Kanban con tres columnas: "Por hacer", "En progreso" y "Completado"
  2. Implementamos todos los eventos del ciclo de arrastrar y soltar para permitir mover tarjetas entre columnas
  3. Proporcionamos retroalimentación visual durante cada fase del proceso
  4. Manejamos correctamente la propagación de eventos para evitar comportamientos inesperados

Consideraciones importantes

Al trabajar con los eventos del ciclo de arrastrar y soltar, es importante tener en cuenta algunas consideraciones:

  • Rendimiento: El evento drag y dragover se disparan con alta frecuencia. Evita operaciones costosas en estos manejadores.
  • Prevención predeterminada: Siempre llama a event.preventDefault() en los eventos dragover y drop para permitir que se complete la operación.
  • Compatibilidad: Aunque la API de Drag and Drop tiene buen soporte en navegadores de escritorio, el soporte en dispositivos móviles es limitado.
  • Accesibilidad: La implementación nativa no proporciona soporte completo para accesibilidad, por lo que es necesario complementarla con controles adicionales.

Dominar los eventos del ciclo de arrastrar y soltar te permitirá crear interfaces interactivas y fluidas que mejoren significativamente la experiencia de usuario en tus aplicaciones web.

Implementación de interacciones drag and drop accesibles

La accesibilidad es un aspecto fundamental en el desarrollo web moderno que garantiza que todas las personas, independientemente de sus capacidades, puedan utilizar nuestras aplicaciones. Aunque la API nativa de Drag and Drop de HTML5 ofrece una interacción intuitiva para usuarios que utilizan dispositivos apuntadores, presenta importantes desafíos de accesibilidad para quienes utilizan lectores de pantalla, navegan exclusivamente con teclado o tienen limitaciones motoras.

Para crear experiencias de arrastrar y soltar verdaderamente inclusivas, necesitamos implementar soluciones complementarias que garanticen que todos los usuarios puedan realizar las mismas acciones.

Problemas de accesibilidad en la API nativa

Antes de abordar las soluciones, es importante entender las principales limitaciones de accesibilidad de la API nativa:

  • No proporciona navegación por teclado por defecto
  • Carece de anuncios adecuados para lectores de pantalla
  • No comunica claramente los estados de la interacción
  • Depende exclusivamente de eventos del ratón o táctiles
  • No ofrece alternativas para usuarios que no pueden realizar acciones de arrastre

Estrategias para implementar drag and drop accesible

1. Proporcionar alternativas con teclado

La primera estrategia consiste en ofrecer una forma de realizar las mismas acciones utilizando solo el teclado:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Drag and Drop Accesible</title>
  <style>
    .container {
      display: flex;
      gap: 20px;
    }
    
    .list {
      width: 200px;
      border: 1px solid #ccc;
      padding: 10px;
    }
    
    .item {
      padding: 10px;
      margin: 5px 0;
      background-color: #f5f5f5;
      border: 1px solid #ddd;
      cursor: move;
    }
    
    .item:focus {
      outline: 2px solid #4a90e2;
    }
    
    .item.dragging {
      opacity: 0.5;
    }
    
    .keyboard-instructions {
      margin-top: 20px;
      padding: 10px;
      background-color: #f8f9fa;
      border-left: 3px solid #4a90e2;
    }
  </style>
</head>
<body>
  <h2>Lista de tareas accesible</h2>
  
  <div class="container">
    <div id="list1" class="list" role="list" aria-label="Tareas pendientes">
      <h3 id="list1-heading">Pendientes</h3>
      <div class="item" draggable="true" tabindex="0" role="listitem"
           aria-grabbed="false" id="item1">
        Diseñar interfaz
      </div>
      <div class="item" draggable="true" tabindex="0" role="listitem"
           aria-grabbed="false" id="item2">
        Implementar API
      </div>
    </div>
    
    <div id="list2" class="list" role="list" aria-label="Tareas completadas">
      <h3 id="list2-heading">Completadas</h3>
      <div class="item" draggable="true" tabindex="0" role="listitem"
           aria-grabbed="false" id="item3">
        Configurar proyecto
      </div>
    </div>
  </div>
  
  <div class="keyboard-instructions">
    <p><strong>Instrucciones para teclado:</strong></p>
    <ul>
      <li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> para seleccionar un elemento</li>
      <li>Usa las teclas de flecha para navegar entre elementos</li>
      <li>Presiona <kbd>Esc</kbd> para cancelar la selección</li>
      <li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> nuevamente para soltar el elemento en la ubicación actual</li>
    </ul>
  </div>
  
  <script>
    // Seleccionamos todos los elementos necesarios
    const items = document.querySelectorAll('.item');
    const lists = document.querySelectorAll('.list');
    
    // Variable para almacenar el elemento seleccionado
    let selectedItem = null;
    
    // Configuramos los eventos para arrastrar con ratón
    items.forEach(item => {
      // Eventos de ratón para drag and drop
      item.addEventListener('dragstart', handleDragStart);
      item.addEventListener('dragend', handleDragEnd);
      
      // Eventos de teclado para accesibilidad
      item.addEventListener('keydown', handleKeyDown);
    });
    
    lists.forEach(list => {
      list.addEventListener('dragover', handleDragOver);
      list.addEventListener('drop', handleDrop);
    });
    
    // Funciones para manejar eventos de ratón
    function handleDragStart(event) {
      this.classList.add('dragging');
      this.setAttribute('aria-grabbed', 'true');
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${this.textContent} seleccionado para mover`);
      
      event.dataTransfer.setData('text/plain', this.id);
    }
    
    function handleDragEnd() {
      this.classList.remove('dragging');
      this.setAttribute('aria-grabbed', 'false');
    }
    
    function handleDragOver(event) {
      event.preventDefault();
    }
    
    function handleDrop(event) {
      event.preventDefault();
      
      const id = event.dataTransfer.getData('text/plain');
      const draggedItem = document.getElementById(id);
      
      // Movemos el elemento a la nueva lista
      this.appendChild(draggedItem);
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${draggedItem.textContent} movido a ${this.getAttribute('aria-label')}`);
    }
    
    // Funciones para manejar eventos de teclado
    function handleKeyDown(event) {
      switch (event.key) {
        case ' ':  // Espacio
        case 'Enter':
          event.preventDefault();
          
          if (selectedItem === this) {
            // Si ya está seleccionado, lo soltamos
            dropWithKeyboard(this);
          } else {
            // Si no está seleccionado, lo seleccionamos
            selectWithKeyboard(this);
          }
          break;
          
        case 'Escape':
          // Cancelar la selección
          if (selectedItem) {
            cancelSelection();
          }
          break;
          
        case 'ArrowUp':
        case 'ArrowDown':
        case 'ArrowLeft':
        case 'ArrowRight':
          // Navegación entre elementos
          navigateWithKeyboard(event);
          break;
      }
    }
    
    function selectWithKeyboard(item) {
      // Si ya hay un elemento seleccionado, lo deseleccionamos
      if (selectedItem) {
        cancelSelection();
      }
      
      // Seleccionamos el nuevo elemento
      selectedItem = item;
      item.classList.add('dragging');
      item.setAttribute('aria-grabbed', 'true');
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${item.textContent} seleccionado. Use las teclas de flecha para navegar y Enter para soltar.`);
    }
    
    function dropWithKeyboard(item) {
      // Obtenemos la lista actual
      const currentList = item.parentNode;
      
      // Deseleccionamos el elemento
      item.classList.remove('dragging');
      item.setAttribute('aria-grabbed', 'false');
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${item.textContent} movido a ${currentList.getAttribute('aria-label')}`);
      
      // Reseteamos la selección
      selectedItem = null;
    }
    
    function cancelSelection() {
      if (selectedItem) {
        selectedItem.classList.remove('dragging');
        selectedItem.setAttribute('aria-grabbed', 'false');
        
        // Anunciamos para lectores de pantalla
        announceToScreenReader(`Selección cancelada`);
        
        selectedItem = null;
      }
    }
    
    function navigateWithKeyboard(event) {
      // Prevenimos el desplazamiento de la página
      event.preventDefault();
      
      // Si hay un elemento seleccionado, lo movemos
      if (selectedItem) {
        const currentList = selectedItem.parentNode;
        let targetList;
        
        switch (event.key) {
          case 'ArrowLeft':
            // Movemos a la lista anterior
            targetList = currentList.previousElementSibling;
            if (targetList && targetList.classList.contains('list')) {
              targetList.appendChild(selectedItem);
              announceToScreenReader(`Movido a ${targetList.getAttribute('aria-label')}`);
            }
            break;
            
          case 'ArrowRight':
            // Movemos a la lista siguiente
            targetList = currentList.nextElementSibling;
            if (targetList && targetList.classList.contains('list')) {
              targetList.appendChild(selectedItem);
              announceToScreenReader(`Movido a ${targetList.getAttribute('aria-label')}`);
            }
            break;
        }
      }
    }
    
    // Función para anunciar mensajes a lectores de pantalla
    function announceToScreenReader(message) {
      // Creamos o actualizamos un elemento live region
      let announcer = document.getElementById('a11y-announcer');
      
      if (!announcer) {
        announcer = document.createElement('div');
        announcer.id = 'a11y-announcer';
        announcer.setAttribute('aria-live', 'assertive');
        announcer.setAttribute('aria-atomic', 'true');
        announcer.className = 'sr-only';
        document.body.appendChild(announcer);
      }
      
      // Limpiamos y luego establecemos el contenido para asegurar que se anuncie
      announcer.textContent = '';
      setTimeout(() => {
        announcer.textContent = message;
      }, 100);
    }
    
    // Estilos para ocultar visualmente el anunciador pero mantenerlo accesible
    const style = document.createElement('style');
    style.textContent = `
      .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }
    `;
    document.head.appendChild(style);
  </script>
</body>
</html>

En este ejemplo:

  • Añadimos tabindex="0" para hacer que los elementos sean focusables
  • Implementamos controles de teclado para seleccionar, mover y soltar elementos
  • Proporcionamos instrucciones claras para usuarios de teclado
  • Utilizamos una región live para anunciar cambios a lectores de pantalla

2. Uso de atributos ARIA para mejorar la semántica

Los atributos ARIA (Accessible Rich Internet Applications) nos permiten mejorar la semántica de nuestros elementos para lectores de pantalla:

<div class="item" 
     draggable="true" 
     tabindex="0" 
     role="listitem"
     aria-grabbed="false" 
     aria-describedby="drag-instructions">
  Elemento arrastrable
</div>

<div id="drag-instructions" class="sr-only">
  Presiona Espacio para seleccionar, luego usa las flechas para mover y Espacio para soltar.
</div>

Los atributos clave incluyen:

  • role="listitem" - Define el rol semántico del elemento
  • aria-grabbed - Indica si el elemento está siendo arrastrado
  • aria-dropeffect - Indica qué ocurrirá si se suelta un elemento (copy, move, etc.)
  • aria-describedby - Vincula el elemento con sus instrucciones de uso

3. Retroalimentación visual clara

Es importante proporcionar retroalimentación visual que no dependa solo del color:

/* Estilo para elemento seleccionado */
.item.selected {
  outline: 2px solid #4a90e2;
  box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.3);
  position: relative;
}

/* Indicador visual adicional */
.item.selected::before {
  content: "✓";
  position: absolute;
  top: 5px;
  right: 5px;
  background: #4a90e2;
  color: white;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Estilo para zonas de destino válidas */
.list.valid-target {
  border: 2px dashed #4a90e2;
  background-color: rgba(74, 144, 226, 0.1);
}

4. Implementación de un patrón de diálogo para dispositivos móviles

Para dispositivos móviles donde el drag and drop nativo no funciona bien, podemos implementar un patrón de diálogo:

<div class="item" onclick="showMoveDialog(this)">
  Elemento móvil
  <button class="move-button" aria-label="Mover elemento">
    <span class="icon">↕</span>
  </button>
</div>

<dialog id="move-dialog">
  <h3>Mover elemento</h3>
  <p>Selecciona dónde mover este elemento:</p>
  <ul id="destination-options">
    <!-- Se llenará dinámicamente -->
  </ul>
  <div class="dialog-buttons">
    <button id="cancel-move">Cancelar</button>
  </div>
</dialog>

<script>
  function showMoveDialog(element) {
    const dialog = document.getElementById('move-dialog');
    const options = document.getElementById('destination-options');
    
    // Guardamos referencia al elemento que se moverá
    dialog.dataset.moveElement = element.id;
    
    // Limpiamos opciones anteriores
    options.innerHTML = '';
    
    // Obtenemos todas las listas disponibles
    const lists = document.querySelectorAll('.list');
    
    // Creamos opciones para cada lista
    lists.forEach(list => {
      const option = document.createElement('li');
      const button = document.createElement('button');
      
      button.textContent = list.querySelector('h3').textContent;
      button.dataset.targetList = list.id;
      button.addEventListener('click', moveToSelected);
      
      option.appendChild(button);
      options.appendChild(option);
    });
    
    // Mostramos el diálogo
    dialog.showModal();
  }
  
  function moveToSelected() {
    const dialog = document.getElementById('move-dialog');
    const elementId = dialog.dataset.moveElement;
    const element = document.getElementById(elementId);
    const targetListId = this.dataset.targetList;
    const targetList = document.getElementById(targetListId);
    
    // Movemos el elemento a la lista seleccionada
    targetList.appendChild(element);
    
    // Anunciamos para lectores de pantalla
    announceToScreenReader(`Elemento movido a ${this.textContent}`);
    
    // Cerramos el diálogo
    dialog.close();
  }
  
  // Configurar botón de cancelar
  document.getElementById('cancel-move').addEventListener('click', function() {
    document.getElementById('move-dialog').close();
  });
</script>

Este enfoque proporciona una alternativa táctil que funciona bien en dispositivos móviles y es completamente accesible.

Ejemplo completo de implementación accesible

A continuación, se presenta un ejemplo más completo que combina todas estas estrategias:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Drag and Drop Accesible Completo</title>
  <style>
    /* Estilos básicos */
    .container {
      display: flex;
      flex-wrap: wrap;
      gap: 20px;
      margin-bottom: 20px;
    }
    
    .list {
      width: 250px;
      border: 1px solid #ddd;
      border-radius: 4px;
      padding: 10px;
      background-color: #f9f9f9;
    }
    
    .list h3 {
      margin-top: 0;
      padding-bottom: 8px;
      border-bottom: 1px solid #eee;
    }
    
    .item {
      padding: 12px;
      margin: 8px 0;
      background-color: white;
      border: 1px solid #eee;
      border-radius: 4px;
      box-shadow: 0 1px 2px rgba(0,0,0,0.05);
      cursor: move;
      position: relative;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    /* Estilos para accesibilidad */
    .item:focus {
      outline: 2px solid #4a90e2;
      box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.3);
    }
    
    .item.dragging {
      opacity: 0.6;
      outline: 2px solid #4a90e2;
    }
    
    .list.drop-target {
      background-color: #e8f4fc;
      border-color: #4a90e2;
    }
    
    .move-button {
      background: #f0f0f0;
      border: none;
      border-radius: 4px;
      width: 30px;
      height: 30px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    
    .move-button:hover {
      background: #e0e0e0;
    }
    
    .sr-only {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
    
    /* Estilos para el diálogo */
    dialog {
      padding: 20px;
      border-radius: 8px;
      border: 1px solid #ddd;
      box-shadow: 0 4px 12px rgba(0,0,0,0.15);
      max-width: 400px;
      width: 100%;
    }
    
    dialog::backdrop {
      background-color: rgba(0,0,0,0.3);
    }
    
    #destination-options {
      list-style: none;
      padding: 0;
    }
    
    #destination-options li {
      margin: 8px 0;
    }
    
    #destination-options button {
      width: 100%;
      padding: 10px;
      text-align: left;
      background: #f5f5f5;
      border: 1px solid #ddd;
      border-radius: 4px;
      cursor: pointer;
    }
    
    #destination-options button:hover,
    #destination-options button:focus {
      background: #e8f4fc;
      border-color: #4a90e2;
    }
    
    .dialog-buttons {
      display: flex;
      justify-content: flex-end;
      margin-top: 20px;
    }
    
    .dialog-buttons button {
      padding: 8px 16px;
      border-radius: 4px;
      cursor: pointer;
    }
    
    #cancel-move {
      background: #f5f5f5;
      border: 1px solid #ddd;
    }
    
    /* Instrucciones de accesibilidad */
    .accessibility-instructions {
      background-color: #f8f9fa;
      border-left: 4px solid #4a90e2;
      padding: 15px;
      margin: 20px 0;
    }
    
    .accessibility-instructions h3 {
      margin-top: 0;
    }
    
    kbd {
      background-color: #f7f7f7;
      border: 1px solid #ccc;
      border-radius: 3px;
      box-shadow: 0 1px 0 rgba(0,0,0,0.2);
      color: #333;
      display: inline-block;
      font-size: 0.85em;
      font-weight: 700;
      line-height: 1;
      padding: 2px 4px;
      white-space: nowrap;
    }
  </style>
</head>
<body>
  <h1>Organizador de tareas accesible</h1>
  
  <div class="accessibility-instructions">
    <h3>Instrucciones de uso</h3>
    <p><strong>Con ratón:</strong> Arrastra y suelta los elementos entre las listas.</p>
    <p><strong>Con teclado:</strong></p>
    <ul>
      <li>Usa <kbd>Tab</kbd> para navegar entre elementos</li>
      <li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> para seleccionar un elemento</li>
      <li>Usa <kbd>←</kbd> <kbd>→</kbd> para mover entre listas</li>
      <li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> nuevamente para soltar</li>
      <li>Presiona <kbd>Esc</kbd> para cancelar</li>
    </ul>
    <p><strong>En dispositivos táctiles:</strong> Toca el botón de mover y selecciona el destino.</p>
  </div>
  
  <div class="container">
    <div id="list1" class="list" role="list" aria-label="Tareas pendientes">
      <h3 id="list1-heading">Pendientes</h3>
      <div id="item1" class="item" draggable="true" tabindex="0" role="listitem"
           aria-grabbed="false" aria-describedby="drag-instructions">
        <span>Diseñar interfaz</span>
        <button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item1')">
          <span aria-hidden="true">↕</span>
        </button>
      </div>
      <div id="item2" class="item" draggable="true" tabindex="0" role="listitem"
           aria-grabbed="false" aria-describedby="drag-instructions">
        <span>Implementar API</span>
        <button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item2')">
          <span aria-hidden="true">↕</span>
        </button>
      </div>
    </div>
    
    <div id="list2" class="list" role="list" aria-label="Tareas en progreso">
      <h3 id="list2-heading">En progreso</h3>
      <div id="item3" class="item" draggable="true" tabindex="0" role="listitem"
           aria-grabbed="false" aria-describedby="drag-instructions">
        <span>Crear documentación</span>
        <button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item3')">
          <span aria-hidden="true">↕</span>
        </button>
      </div>
    </div>
    
    <div id="list3" class="list" role="list" aria-label="Tareas completadas">
      <h3 id="list3-heading">Completadas</h3>
      <div id="item4" class="item" draggable="true" tabindex="0" role="listitem"
           aria-grabbed="false" aria-describedby="drag-instructions">
        <span>Configurar proyecto</span>
        <button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item4')">
          <span aria-hidden="true">↕</span>
        </button>
      </div>
    </div>
  </div>
  
  <!-- Instrucciones ocultas para lectores de pantalla -->
  <div id="drag-instructions" class="sr-only">
    Elemento arrastrable. Presiona Espacio para seleccionar, luego usa las flechas izquierda o derecha para mover entre listas, y Espacio nuevamente para soltar.
  </div>
  
  <!-- Diálogo para dispositivos móviles -->
  <dialog id="move-dialog">
    <h3>Mover tarea</h3>
    <p>Selecciona a qué lista mover esta tarea:</p>
    <ul id="destination-options">
      <!-- Se llenará dinámicamente -->
    </ul>
    <div class="dialog-buttons">
      <button id="cancel-move">Cancelar</button>
    </div>
  </dialog>
  
  <script>
    // Seleccionamos todos los elementos necesarios
    const items = document.querySelectorAll('.item');
    const lists = document.querySelectorAll('.list');
    
    // Variable para almacenar el elemento seleccionado
    let selectedItem = null;
    
    // Configuramos los eventos para arrastrar con ratón
    items.forEach(item => {
      // Eventos de ratón para drag and drop
      item.addEventListener('dragstart', handleDragStart);
      item.addEventListener('dragend', handleDragEnd);
      
      // Eventos de teclado para accesibilidad
      item.addEventListener('keydown', handleKeyDown);
    });
    
    lists.forEach(list => {
      list.addEventListener('dragenter', handleDragEnter);
      list.addEventListener('dragover', handleDragOver);
      list.addEventListener('dragleave', handleDragLeave);
      list.addEventListener('drop', handleDrop);
    });
    
    // Funciones para manejar eventos de ratón
    function handleDragStart(event) {
      this.classList.add('dragging');
      this.setAttribute('aria-grabbed', 'true');
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${this.querySelector('span').textContent} seleccionado para mover`);
      
      event.dataTransfer.setData('text/plain', this.id);
    }
    
    function handleDragEnd() {
      this.classList.remove('dragging');
      this.setAttribute('aria-grabbed', 'false');
    }
    
    function handleDragEnter(event) {
      if (event.target.classList.contains('list')) {
        this.classList.add('drop-target');
      }
    }
    
    function handleDragOver(event) {
      event.preventDefault();
    }
    
    function handleDragLeave(event) {
      if (event.relatedTarget && !this.contains(event.relatedTarget)) {
        this.classList.remove('drop-target');
      }
    }
    
    function handleDrop(event) {
      event.preventDefault();
      this.classList.remove('drop-target');
      
      const id = event.dataTransfer.getData('text/plain');
      const draggedItem = document.getElementById(id);
      
      // Movemos el elemento a la nueva lista
      this.appendChild(draggedItem);
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${draggedItem.querySelector('span').textContent} movido a ${this.getAttribute('aria-label')}`);
    }
    
    // Funciones para manejar eventos de teclado
    function handleKeyDown(event) {
      switch (event.key) {
        case ' ':  // Espacio
        case 'Enter':
          event.preventDefault();
          
          if (selectedItem === this) {
            // Si ya está seleccionado, lo soltamos
            dropWithKeyboard(this);
          } else {
            // Si no está seleccionado, lo seleccionamos
            selectWithKeyboard(this);
          }
          break;
          
        case 'Escape':
          // Cancelar la selección
          if (selectedItem) {
            cancelSelection();
          }
          break;
          
        case 'ArrowLeft':
        case 'ArrowRight':
          // Navegación entre listas
          if (selectedItem) {
            event.preventDefault();
            moveToAdjacentList(event.key);
          }
          break;
      }
    }
    
    function selectWithKeyboard(item) {
      // Si ya hay un elemento seleccionado, lo deseleccionamos
      if (selectedItem) {
        cancelSelection();
      }
      
      // Seleccionamos el nuevo elemento
      selectedItem = item;
      item.classList.add('dragging');
      item.setAttribute('aria-grabbed', 'true');
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${item.querySelector('span').textContent} seleccionado. Use las flechas izquierda o derecha para mover entre listas y Enter para soltar.`);
    }
    
    function dropWithKeyboard(item) {
      // Obtenemos la lista actual
      const currentList = item.parentNode;
      
      // Deseleccionamos el elemento
      item.classList.remove('dragging');
      item.setAttribute('aria-grabbed', 'false');
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${item.querySelector('span').textContent} colocado en ${currentList.getAttribute('aria-label')}`);
      
      // Reseteamos la selección
      selectedItem = null;
    }
    
    function cancelSelection() {
      if (selectedItem) {
        selectedItem.classList.remove('dragging');
        selectedItem.setAttribute('aria-grabbed', 'false');
        
        // Anunciamos para lectores de pantalla
        announceToScreenReader(`Selección cancelada`);
        
        selectedItem = null;
      }
    }
    
    function moveToAdjacentList(direction) {
      if (!selectedItem) return;
      
      const currentList = selectedItem.parentNode;
      let targetList;
      
      if (direction === 'ArrowLeft') {
        targetList = getPreviousList(currentList);
      } else if (direction === 'ArrowRight') {
        targetList = getNextList(currentList);
      }
      
      if (targetList) {
        targetList.appendChild(selectedItem);
        announceToScreenReader(`Movido a ${targetList.getAttribute('aria-label')}`);
      }
    }
    
    function getPreviousList(currentList) {
      let prev = currentList.previousElementSibling;
      while (prev) {
        if (prev.classList.contains('list')) {
          return prev;
        }
        prev = prev.previousElementSibling;
      }
      return null;
    }
    
    function getNextList(currentList) {
      let next = currentList.nextElementSibling;
      while (next) {
        if (next.classList.contains('list')) {
          return next;
        }
        next = next.nextElementSibling;
      }
      return null;
    }
    
    // Función para mostrar el diálogo de movimiento (para dispositivos táctiles)
    function showMoveDialog(itemId) {
      const dialog = document.getElementById('move-dialog');
      const options = document.getElementById('destination-options');
      const item = document.getElementById(itemId);
      
      // Guardamos referencia al elemento que se moverá
      dialog.dataset.moveElement = itemId;
      
      // Limpiamos opciones anteriores
      options.innerHTML = '';
      
      // Obtenemos todas las listas disponibles
      const lists = document.querySelectorAll('.list');
      
      // Creamos opciones para cada lista
      lists.forEach(list => {
        const option = document.createElement('li');
        const button = document.createElement('button');
        
        button.textContent = list.querySelector('h3').textContent;
        button.dataset.targetList = list.id;
        button.addEventListener('click', moveToSelected);
        
        // Destacamos la lista actual
        if (item.parentNode === list) {
          button.setAttribute('aria-current', 'true');
          button.style.fontWeight = 'bold';
        }
        
        option.appendChild(button);
        options.appendChild(option);
      });
      
      // Mostramos el diálogo
      dialog.showModal();
    }
    
    function moveToSelected() {
      const dialog = document.getElementById('move-dialog');
      const elementId = dialog.dataset.moveElement;
      const element = document.getElementById(elementId);
      const targetListId = this.dataset.targetList;
      const targetList = document.getElementById(targetListId);
      
      // Movemos el elemento a la lista seleccionada
      targetList.appendChild(element);
      
      // Anunciamos para lectores de pantalla
      announceToScreenReader(`Elemento ${element.querySelector('span').textContent} movido a ${this.textContent}`);
      
      // Cerramos el diálogo
      dialog.close();
    }
    
    // Configurar botón de cancelar
    document.getElementById('cancel-move').addEventListener('click', function() {
      document.getElementById('move-dialog').close();
    });
    
    // Función para anunciar mensajes a lectores de pantalla
    function announceToScreenReader(message) {
      // Creamos o actualizamos un elemento live region
      let announcer = document.getElementById('a11y-announcer');
      
      if (!announcer) {
        announcer = document.createElement('div');
        announcer.id = 'a11y-announcer';
        announcer.setAttribute('aria-live', 'assertive');
        announcer.setAttribute('aria-atomic', 'true');
        announcer.className = 'sr-only';
        document.body.appendChild(announcer);
      }
      
      // Limpiamos y luego establecemos el contenido para asegurar que se anuncie
      announcer.textContent = '';
      setTimeout(() => {
        announcer.textContent = message;
      }, 100);
    }
  </script>
</body>
</html>

Mejores prácticas para drag and drop accesible

Para garantizar que tus implementaciones de drag and drop sean verdaderamente accesibles, sigue estas mejores prácticas:

  • Proporciona múltiples métodos de interacción: Implementa alternativas con teclado y táctiles además del arrastre con ratón.

  • Usa atributos ARIA apropiados: Utiliza aria-grabbed, aria-dropeffect y otros atributos relevantes para comunicar el estado a los lectores de pantalla.

  • Ofrece retroalimentación clara: Proporciona indicaciones visuales, auditivas y táctiles sobre lo que está sucediendo.

  • Incluye instrucciones explícitas: Documenta claramente cómo usar la funcionalidad con diferentes dispositivos de entrada.

  • Utiliza regiones live para anuncios: Implementa regiones live de ARIA para comunicar cambios dinámicos a los lectores de pantalla.

  • Prueba con tecnologías de asistencia: Verifica que tu implementación funcione correctamente con lectores de pantalla como NVDA, JAWS o VoiceOver.

  • Mantén un alto contraste: Asegúrate de que los indicadores visuales tengan suficiente contraste para usuarios con baja visión.

  • Evita depender solo del color: Utiliza formas, iconos o patrones además del color para transmitir información.

  • Implementa atajos de teclado intuitivos: Usa combinaciones de teclas que sean coherentes con las convenciones de la plataforma.

  • Ofrece mecanismos de cancelación: Permite a los usuarios cancelar fácilmente una operación de arrastre en curso.

Pruebas de accesibilidad

Para verificar que tu implementación de drag and drop es realmente accesible, realiza las siguientes pruebas:

  1. Navegación por teclado: Intenta completar todas las operaciones usando solo el teclado.

  2. Lectores de pantalla: Prueba con NVDA, JAWS o VoiceOver para verificar que los anuncios son claros y útiles.

  3. Modo de alto contraste: Verifica que la interfaz sigue siendo usable en modo de alto contraste.

  4. Dispositivos táctiles: Comprueba que la alternativa táctil funciona correctamente en teléfonos y tabletas.

  5. Diferentes tamaños de texto: Asegúrate de que la interfaz se adapta correctamente cuando los usuarios aumentan el tamaño del texto.

Implementar interacciones de drag and drop accesibles requiere más trabajo que simplemente usar la API nativa, pero el resultado es una experiencia inclusiva que beneficia a todos los usuarios, independientemente de sus capacidades o dispositivos. Al seguir estas pautas, crearás interfaces que no solo son más accesibles, sino también más usables y robustas para todos.

Aprendizajes de esta lección de HTML

  • Comprender el funcionamiento básico de la API nativa de Drag and Drop en HTML5.
  • Aprender a usar el atributo draggable y manejar los eventos asociados al ciclo de arrastrar y soltar.
  • Implementar transferencia de datos entre elementos mediante el objeto DataTransfer.
  • Crear zonas de destino para recibir elementos arrastrados y gestionar la interacción.
  • Diseñar interacciones accesibles para usuarios con diferentes capacidades, incluyendo soporte para teclado y lectores de pantalla.

Completa este curso de HTML y certifícate

Únete a nuestra plataforma de cursos de programación y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.

Asistente IA

Resuelve dudas al instante

Ejercicios

Practica con proyectos reales

Certificados

Valida tus conocimientos

Más de 25.000 desarrolladores ya se han certificado con CertiDevs

⭐⭐⭐⭐⭐
4.9/5 valoración