Patrón resuelto: arrastrar y soltar con reordenación
El gesto con Pointer Events y captura, el reordenado en vivo con FLIP mientras arrastras, el asentamiento al soltar, y la alternativa de teclado que WCAG 2.2 exige.
Arrastrar y soltar es el patrón donde se juntan todas las piezas del track: entrada continua, movimiento que tiene que responder al dedo sin ninguna latencia, reordenación del DOM en vivo, interrupción en cualquier instante y una obligación de accesibilidad que la mayoría de las implementaciones incumple. Es también el patrón donde una librería genérica suele estorbar más de lo que ayuda, porque el comportamiento correcto depende demasiado de tu contenido.
- Implementar el gesto con Pointer Events, captura y umbral de activación.
- Reordenar la lista mientras se arrastra sin que el elemento arrastrado salte.
- Asentar el elemento al soltar con una curva que comunique el destino.
- Proporcionar la alternativa de teclado que exige el criterio 2.5.7 de WCAG 2.2.
El gesto
Pointer Events unifica ratón, dedo y lápiz en una sola API, y setPointerCapture resuelve el problema que hacía complicado el arrastre: garantiza que todos los eventos del puntero sigan llegando al mismo elemento aunque el cursor salga de él, incluido el pointerup fuera de la ventana.
<ul id="lista" class="ordenable">
<li class="item" data-id="a">
<button class="asa" type="button" aria-describedby="ayuda-arrastre">
<span class="rotulo-oculto">Reordenar: Diseño del sistema</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="16" height="16">
<path d="M6 3h1v1H6zM9 3h1v1H9zM6 7h1v1H6zM9 7h1v1H9zM6 11h1v1H6zM9 11h1v1H9z"
fill="currentColor" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
<span class="etiqueta">Diseño del sistema</span>
</li>
</ul>
<p id="ayuda-arrastre" class="rotulo-oculto">
Pulsa Espacio para levantar el elemento, flechas arriba y abajo para moverlo,
Espacio para soltar y Escape para cancelar.
</p>
<div id="anuncios" role="status" aria-live="polite" class="rotulo-oculto"></div>
.ordenable { list-style: none; margin: 0; padding: 0; }
.item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: canvas;
border-radius: 8px;
}
/* Sin esto, el navegador interpreta el gesto como desplazamiento
y tu pointermove nunca llega en tactil. */
.asa { touch-action: none; cursor: grab; background: none; border: 0; }
.item[data-arrastrando] {
cursor: grabbing;
z-index: 2;
position: relative;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
}
.rotulo-oculto {
position: absolute;
inline-size: 1px; block-size: 1px;
overflow: hidden; clip-path: inset(50%);
white-space: nowrap;
}
const lista = document.getElementById("lista");
const anuncios = document.getElementById("anuncios");
const UMBRAL = 5;
let arrastre = null;
lista.addEventListener("pointerdown", (e) => {
const asa = e.target.closest(".asa");
if (!asa || e.button !== 0) return;
e.preventDefault();
asa.setPointerCapture(e.pointerId);
arrastre = {
item: asa.closest(".item"),
asa,
puntero: e.pointerId,
origenY: e.clientY,
dy: 0,
activo: false,
};
});
lista.addEventListener("pointermove", (e) => {
if (!arrastre || e.pointerId !== arrastre.puntero) return;
arrastre.dy = e.clientY - arrastre.origenY;
// Umbral: hasta que no se supera, esto todavia puede ser un clic.
if (!arrastre.activo) {
if (Math.abs(arrastre.dy) < UMBRAL) return;
activar();
}
arrastre.item.style.transform = `translate3d(0, ${arrastre.dy}px, 0)`;
reubicar(e.clientY);
});
function activar() {
arrastre.activo = true;
arrastre.item.dataset.arrastrando = "";
// La capa se pide aqui y se suelta al terminar, no en la hoja de estilos.
arrastre.item.style.willChange = "transform";
anunciar(`${texto(arrastre.item)} levantado. Posición ${indice(arrastre.item) + 1} de ${lista.children.length}.`);
}
const texto = (el) => el.querySelector(".etiqueta").textContent.trim();
const indice = (el) => [...lista.children].indexOf(el);
const anunciar = (mensaje) => { anuncios.textContent = mensaje; };
El umbral de cinco píxeles es imprescindible en táctil: sin él, cualquier pulsación con un micromovimiento inicia un arrastre y el usuario no puede pulsar nada.
Reordenar mientras arrastras
La lógica: comparar la posición vertical del puntero con el punto medio de cada hermano y, si el elemento arrastrado debería estar en otro sitio, moverlo en el DOM. El problema es que mover el nodo cambia su posición base, y si no lo compensas, el elemento salta bajo el dedo.
La compensación es de una línea y es la pieza que hace que esto funcione: offsetTop no se ve afectado por transform, así que la diferencia de offsetTop antes y después del movimiento es exactamente cuánto se ha desplazado la base, y sumándola al origen del gesto la posición visual se conserva.
function reubicar(clienteY) {
const item = arrastre.item;
const hermanos = [...lista.children].filter((el) => el !== item);
// A donde deberia ir segun el punto medio de cada hermano.
let destino = null;
for (const hermano of hermanos) {
const caja = hermano.getBoundingClientRect();
const medio = caja.top + caja.height / 2;
if (clienteY < medio) { destino = hermano; break; }
}
const siguienteActual = item.nextElementSibling;
if (destino === siguienteActual || destino === item) return;
// FIRST de los hermanos, para animarlos con FLIP.
const antes = new Map(hermanos.map((el) => [el, el.getBoundingClientRect().top]));
const baseAntes = item.offsetTop;
destino ? lista.insertBefore(item, destino) : lista.append(item);
// Compensacion: la base se ha movido, asi que el desplazamiento acumulado
// y el origen del gesto se corrigen en la misma cantidad. La posicion
// visual, que es base mas dy, no cambia.
const desplazamientoBase = item.offsetTop - baseAntes;
arrastre.origenY += desplazamientoBase;
arrastre.dy -= desplazamientoBase;
item.style.transform = `translate3d(0, ${arrastre.dy}px, 0)`;
// LAST, INVERT y PLAY para los hermanos que se han recolocado.
for (const el of hermanos) {
const delta = antes.get(el) - el.getBoundingClientRect().top;
if (Math.abs(delta) < 0.5) continue;
for (const a of el.getAnimations()) if (a.id === "orden") a.cancel();
el.animate(
[{ transform: `translateY(${delta}px)` }, { transform: "none" }],
{ duration: 180, easing: "cubic-bezier(0.4, 0, 0.2, 1)", id: "orden" }
);
}
anunciar(`${texto(item)} en posición ${indice(item) + 1} de ${lista.children.length}.`);
}
Los ciento ochenta milisegundos del reordenado son deliberadamente cortos: ese movimiento ocurre durante el gesto, compitiendo por la atención con el elemento que la persona está arrastrando, y una duración mayor produce una lista que parece líquida.
Soltar y asentar
Al soltar, el elemento tiene un transform acumulado y su sitio real está en cero. La animación de vuelta es lo que comunica que ha caído en su posición.
function terminar(e) {
if (!arrastre || e.pointerId !== arrastre.puntero) return;
const { item, asa, activo } = arrastre;
arrastre = null;
if (asa.hasPointerCapture(e.pointerId)) asa.releasePointerCapture(e.pointerId);
if (!activo) return; // fue un clic, no un arrastre
delete item.dataset.arrastrando;
const anim = item.animate(
[{ transform: item.style.transform || "none" }, { transform: "none" }],
{ duration: 250, easing: "cubic-bezier(0.2, 0.9, 0.3, 1)", fill: "both", id: "asentar" }
);
anim.finished.then(() => {
anim.cancel();
item.style.transform = "";
item.style.willChange = ""; // se suelta la capa
});
anunciar(`${texto(item)} soltado en posición ${indice(item) + 1}.`);
guardarOrden([...lista.children].map((el) => el.dataset.id));
}
lista.addEventListener("pointerup", terminar);
lista.addEventListener("pointercancel", terminar);
La curva cubic-bezier(0.2, 0.9, 0.3, 1) frena rápido al principio y se asienta al final sin sobrepasar. Un rebote aquí es tentador y suele ser un error: sugiere que el elemento no ha terminado de decidir dónde va, justo cuando lo que quieres comunicar es lo contrario.
Y pointercancel no es opcional. El sistema operativo cancela el puntero cuando aparece una llamada, cuando el navegador decide que el gesto era un desplazamiento, o cuando se agota el tiempo de una pulsación larga. Sin manejarlo, el elemento se queda flotando para siempre.
Poner will-change: transform en la clase .item de la hoja de estilos crea una capa de composición por cada elemento de la lista, permanentemente, para un gesto que ocurre unos segundos al día. Con cuarenta elementos son decenas de megabytes de memoria de vídeo ocupada sin ningún beneficio.
La alternativa de teclado
El criterio 2.5.7 Movimientos de arrastre, de nivel AA e incorporado en WCAG 2.2, exige que toda funcionalidad que se opere arrastrando tenga una alternativa con un solo puntero, salvo que el arrastre sea esencial. Una lista reordenable no lo es. Y con teclado el requisito viene además de los criterios de operabilidad de siempre.
El patrón establecido es levantar, mover y soltar, con anuncios en cada paso:
let levantado = null;
lista.addEventListener("keydown", (e) => {
const asa = e.target.closest(".asa");
if (!asa) return;
const item = asa.closest(".item");
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (!levantado) {
levantado = { item, origen: indice(item) };
item.dataset.arrastrando = "";
anunciar(`${texto(item)} levantado. Posición ${indice(item) + 1} de ${lista.children.length}. Usa las flechas para moverlo.`);
} else {
soltarTeclado();
}
return;
}
if (!levantado || levantado.item !== item) return;
if (e.key === "Escape") {
e.preventDefault();
const hermanos = [...lista.children];
const destino = hermanos[levantado.origen];
moverConFlip(item, () => destino ? lista.insertBefore(item, destino) : lista.append(item));
anunciar(`Movimiento cancelado. ${texto(item)} vuelve a la posición ${levantado.origen + 1}.`);
delete item.dataset.arrastrando;
levantado = null;
asa.focus();
return;
}
const paso = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0;
if (!paso) return;
e.preventDefault();
const i = indice(item);
const destino = i + paso;
if (destino < 0 || destino >= lista.children.length) return;
moverConFlip(item, () => {
const referencia = [...lista.children][paso === -1 ? destino : destino + 1];
referencia ? lista.insertBefore(item, referencia) : lista.append(item);
});
anunciar(`Posición ${indice(item) + 1} de ${lista.children.length}.`);
asa.focus();
});
function soltarTeclado() {
const item = levantado.item;
delete item.dataset.arrastrando;
anunciar(`${texto(item)} soltado en posición ${indice(item) + 1}.`);
guardarOrden([...lista.children].map((el) => el.dataset.id));
levantado = null;
}
function moverConFlip(item, mutar) {
const elementos = [...lista.children];
const antes = new Map(elementos.map((el) => [el, el.getBoundingClientRect().top]));
mutar();
const reducido = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reducido) return;
for (const el of elementos) {
const delta = antes.get(el) - el.getBoundingClientRect().top;
if (Math.abs(delta) < 0.5) continue;
for (const a of el.getAnimations()) if (a.id === "orden") a.cancel();
el.animate(
[{ transform: `translateY(${delta}px)` }, { transform: "none" }],
{ duration: 180, easing: "cubic-bezier(0.4, 0, 0.2, 1)", id: "orden" }
);
}
}
Tres detalles que hacen que la versión de teclado sea utilizable de verdad: el foco vuelve al asa después de cada movimiento, porque mover el nodo en el DOM lo pierde; cada paso se anuncia con la posición y el total, porque quien no ve la lista necesita saber dónde está; y Escape cancela devolviendo al origen, porque sin salida de emergencia nadie se atreve a usarlo.
Todo lo que has visto sobre duraciones, curvas y coreografía asume una premisa que aquí desaparece: que tú controlas el tiempo. Una transición dura lo que tú decides, con la curva que tú eliges, y la persona la observa. En un arrastre, la posición del elemento es una función directa de la posición del dedo, y cualquier cosa que interpongas entre las dos —una curva, un retardo, un suavizado, un muelle— se percibe de inmediato como que el elemento no responde. Es la única situación de toda la interfaz donde la respuesta correcta a “qué easing uso” es ninguno y la respuesta a “cuánto dura” es exactamente lo que dure el gesto. Esto tiene una consecuencia que reordena las prioridades: durante el arrastre, la única propiedad que importa es la latencia, y todo lo demás es secundario. Un elemento que sigue al dedo con dieciséis milisegundos de retraso se siente pegado a él; con cincuenta, se siente como si lo arrastrara con una goma. Por eso el arrastre se escribe siempre igual —escribir transform directamente desde el manejador de puntero, sin transiciones, sin librería de por medio, sin pasar por el estado de un framework— y por eso una transición CSS declarada sobre el elemento arrastrado, aunque sea de sesenta milisegundos, arruina la sensación aunque sobre el papel parezca imperceptible. Y el momento en que las reglas normales vuelven es exactamente el instante en que se suelta: a partir de ahí ya no hay dedo, el sistema recupera el control del tiempo, y toda la coreografía —la curva de asentamiento, la duración derivada de la distancia, la reordenación de los hermanos— vuelve a aplicarse. Un arrastre bien hecho es, en realidad, dos animaciones con filosofías opuestas pegadas por un evento: una que obedece y otra que explica. Confundirlas, aplicando el suavizado de la segunda a la primera, es el defecto más común y el más fácil de reconocer una vez que sabes qué estás buscando.