dnd-kit - Arrastrar y soltar ligero y accesible para React con listas ordenables y múltiples contenedores
Receta
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities"use client";
import { useState } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
function SortableItem({ id }: { id: string }) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="p-3 bg-white border rounded shadow-sm cursor-grab"
>
{id}
</div>
);
}
export default function SortableList() {
const [items, setItems] = useState(["Item 1", "Item 2", "Item 3"]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{items.map((id) => (
<SortableItem key={id} id={id} />
))}
</div>
</SortableContext>
</DndContext>
);
}Cuándo usarlo: Necesitas reordenamiento de arrastrar y soltar, listas ordenables, tableros kanban o elementos de interfaz arrastrables con accesibilidad de teclado.
Ejemplo funcional
// app/components/KanbanBoard.tsx
"use client";
import { useState } from "react";
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDroppable } from "@dnd-kit/core";
interface Task {
id: string;
title: string;
}
type Columns = Record<string, Task[]>;
function TaskCard({ task }: { task: Task }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="p-3 bg-white border rounded shadow-sm cursor-grab"
>
{task.title}
</div>
);
}
function Column({ id, tasks }: { id: string; tasks: Task[] }) {
const { setNodeRef } = useDroppable({ id });
return (
<div ref={setNodeRef} className="w-72 bg-gray-100 rounded-lg p-4">
<h3 className="font-bold mb-3 capitalize">{id}</h3>
<SortableContext
items={tasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 min-h-[100px]">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
</SortableContext>
</div>
);
}
export default function KanbanBoard() {
const [columns, setColumns] = useState<Columns>({
todo: [
{ id: "1", title: "Design mockups" },
{ id: "2", title: "Write tests" },
],
"in-progress": [{ id: "3", title: "Build API" }],
done: [{ id: "4", title: "Setup repo" }],
});
const [activeTask, setActiveTask] = useState<Task | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
function findColumn(taskId: string): string | undefined {
for (const [columnId, tasks] of Object.entries(columns)) {
if (tasks.some((t) => t.id === taskId)) return columnId;
}
// Check if taskId is a column id (for dropping on empty columns)
if (columns[taskId]) return taskId;
return undefined;
}
function handleDragStart(event: DragStartEvent) {
const col = findColumn(event.active.id as string);
if (col) {
const task = columns[col].find((t) => t.id === event.active.id);
setActiveTask(task ?? null);
}
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeCol = findColumn(active.id as string);
const overCol = findColumn(over.id as string);
if (!activeCol || !overCol || activeCol === overCol) return;
setColumns((prev) => {
const activeItems = [...prev[activeCol]];
const overItems = [...prev[overCol]];
const activeIndex = activeItems.findIndex((t) => t.id === active.id);
const [movedTask] = activeItems.splice(activeIndex, 1);
const overIndex = overItems.findIndex((t) => t.id === over.id);
overItems.splice(overIndex >= 0 ? overIndex : overItems.length, 0, movedTask);
return { ...prev, [activeCol]: activeItems, [overCol]: overItems };
});
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveTask(null);
if (!over) return;
const activeCol = findColumn(active.id as string);
const overCol = findColumn(over.id as string);
if (!activeCol || !overCol || activeCol !== overCol) return;
const items = columns[activeCol];
const oldIndex = items.findIndex((t) => t.id === active.id);
const newIndex = items.findIndex((t) => t.id === over.id);
if (oldIndex !== newIndex) {
setColumns((prev) => ({
...prev,
[activeCol]: arrayMove(prev[activeCol], oldIndex, newIndex),
}));
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 p-6">
{Object.entries(columns).map(([id, tasks]) => (
<Column key={id} id={id} tasks={tasks} />
))}
</div>
<DragOverlay>
{activeTask ? (
<div className="p-3 bg-white border rounded shadow-lg rotate-3">
{activeTask.title}
</div>
) : null}
</DragOverlay>
</DndContext>
);
}Lo que esto demuestra:
- Arrastrar y soltar en múltiples contenedores (columnas estilo kanban)
DragOverlaypara una vista previa de arrastre personalizada- Movimiento de elementos entre contenedores a través de
onDragOver - Reordenamiento dentro de un contenedor a través de
onDragEnd - Restricción de activación para evitar arrastres accidentales
- Accesibilidad de teclado con
KeyboardSensor
Análisis profundo
Cómo funciona
DndContextes el proveedor raíz que gestiona el estado de arrastre, sensores y detección de colisiones- Los sensores (
PointerSensor,KeyboardSensor,TouchSensor) determinan cómo se inician y rastrean las interacciones de arrastre useSortablecombinauseDraggableyuseDroppableen un solo hook para elementos que son arrastrables y soltablesSortableContextrastrea el orden de los elementos y proporciona la estrategia de ordenación (vertical, horizontal o cuadrícula)- Los algoritmos de detección de colisiones (
closestCenter,closestCorners,rectIntersection) determinan cuál es el objetivo soltable activo durante un arrastre DragOverlayrenderiza un elemento flotante que sigue el cursor, separado de la posición DOM del elemento arrastradoarrayMovees una utilidad que reordena un array moviendo un elemento de un índice a otro
Variaciones
Lista ordenable horizontal:
import { horizontalListSortingStrategy } from "@dnd-kit/sortable";
<SortableContext items={items} strategy={horizontalListSortingStrategy}>
<div className="flex gap-2">
{items.map((id) => (
<SortableItem key={id} id={id} />
))}
</div>
</SortableContext>Asa de arrastre (solo una parte del elemento es arrastrable):
function SortableItemWithHandle({ id }: { id: string }) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className="flex items-center gap-2 p-3 border rounded">
<button {...attributes} {...listeners} className="cursor-grab">
☰
</button>
<span>{id}</span>
</div>
);
}Restricción del eje de arrastre:
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
<DndContext modifiers={[restrictToVerticalAxis]}>
{/* ... */}
</DndContext>Notas de TypeScript
DragEndEvent,DragStartEvent,DragOverEventproporcionan objetosactiveyovertipadosactive.idyover.idsonUniqueIdentifier(string o number)- Convierte con
as stringcuando sabes que tus IDs son strings useSortabledevuelve un objeto tipado; desestructura solo las propiedades que necesitas
import type { DragEndEvent, UniqueIdentifier } from "@dnd-kit/core";
function handleDragEnd(event: DragEndEvent) {
const activeId: UniqueIdentifier = event.active.id;
const overId: UniqueIdentifier | undefined = event.over?.id;
}Trampas
-
Los elementos saltan al empezar a arrastrar — El espacio del elemento arrastrado se colapsa, causando cambio de diseño. Solución: Usa
DragOverlaypara la vista previa visual y mantén el elemento original en su lugar con opacidad reducida. -
Arrastres accidentales al hacer clic — Los clics individuales activan eventos de arrastre. Solución: Añade
activationConstraint: { distance: 8 }aPointerSensorpara requerir 8px de movimiento antes de activar. -
Los IDs deben ser únicos en todos los contenedores — Si dos elementos en diferentes columnas comparten un ID, dnd-kit no puede determinar el contenedor correcto. Solución: Usa IDs globalmente únicos (p. ej., UUIDs) para todos los elementos arrastrables.
-
Desincronización de hidratación SSR — dnd-kit usa mediciones DOM que no existen en el servidor. Solución: Asegúrate de que el componente esté marcado como
"use client"y envuélvelo en un límite de solo cliente si es necesario. -
Rendimiento con muchos elementos — Renderizar cientos de elementos ordenables causa retrasos. Solución: Usa virtualización (p. ej.,
react-virtual) y solo envuelve elementos visibles enuseSortable.
Alternativas
| Librería | Lo mejor para | Compensación |
|---|---|---|
| dnd-kit | DnD moderno, accesible y componible | Requiere más configuración para casos complejos |
| react-beautiful-dnd | Listas ordenables simples | Obsoleto, ya no se mantiene |
| react-dnd | Interacciones DnD personalizadas complejas | Curva de aprendizaje más pronunciada, limitaciones del backend HTML5 |
| @hello-pangea/dnd | Reemplazo compatible con react-beautiful-dnd | Fork, mantenido por la comunidad |
| Arrastrar nativo HTML | Arrastre simple sin librerías | Sin accesibilidad de teclado, control limitado |
Preguntas frecuentes
¿Cuáles son los paquetes principales que necesito instalar para una lista ordenable?
@dnd-kit/core-- proporcionaDndContext, sensores y detección de colisiones@dnd-kit/sortable-- proporcionaSortableContext,useSortableyarrayMove@dnd-kit/utilities-- proporcionaCSS.Transform.toStringpara aplicar transformaciones- Instala los tres para una lista ordenable:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
¿Cuál es el papel de DndContext y por qué es obligatorio?
DndContextes el proveedor raíz que gestiona todo el estado de arrastrar y soltar- Coordina sensores, detección de colisiones y manejadores de eventos
- Todos los elementos arrastrables y soltables deben ser descendientes de
DndContext - Proporciona devoluciones de llamada
onDragStart,onDragOveryonDragEnd
¿Cómo difiere useSortable de usar useDraggable y useDroppable por separado?
useSortablecombina ambos hooks en uno para elementos que son tanto arrastrables como soltables- Maneja la transformación y transición de ordenación automáticamente
- Usa
useDraggable+useDroppablepor separado solo cuando los elementos son arrastrables pero no ordenables
Trampa: ¿Por qué mis elementos saltan cuando empiezo a arrastrar?
- El espacio del elemento arrastrado se colapsa cuando se remueve del flujo, causando cambio de diseño
- Usa
DragOverlaypara renderizar una vista previa flotante que siga el cursor - Mantén el elemento original en su lugar con
opacity: 0.5mientras arrastras DragOverlayse renderiza fuera del flujo DOM normal y evita cambios de diseño
¿Cómo evito arrastres accidentales al hacer clic en elementos?
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);La restricción distance: 8 requiere 8px de movimiento antes de que se active un arrastre.
¿Cómo implemento un asa de arrastre para que solo una parte del elemento sea arrastrable?
function SortableItem({ id }: { id: string }) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
return (
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }}>
<button {...attributes} {...listeners}>Drag handle</button>
<span>{id}</span>
</div>
);
}Aplica attributes y listeners al elemento del asa, no al contenedor.
¿Cómo restrinjo el arrastre solo al eje vertical?
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
<DndContext modifiers={[restrictToVerticalAxis]}>
{/* ... */}
</DndContext>¿Cómo escribo el manejador DragEndEvent en TypeScript?
import type { DragEndEvent, UniqueIdentifier } from "@dnd-kit/core";
function handleDragEnd(event: DragEndEvent) {
const activeId: UniqueIdentifier = event.active.id;
const overId: UniqueIdentifier | undefined = event.over?.id;
}UniqueIdentifier es string | number. Convierte con as string si tus IDs son siempre strings.
Trampa: ¿Por qué dnd-kit se rompe con SSR o causa desincronizaciones de hidratación?
- dnd-kit usa mediciones DOM que no existen durante el renderizado del servidor
- Asegúrate de que los componentes que usan dnd-kit estén marcados como
"use client" - Si ves errores de hidratación, envuelve el componente dnd-kit en un límite de solo cliente
¿Qué algoritmos de detección de colisiones están disponibles y cuándo debo usar cada uno?
closestCenter-- lo mejor para listas ordenables de un solo contenedorclosestCorners-- lo mejor para configuraciones de múltiples contenedores (tableros kanban)rectIntersection-- se activa cuando los rectángulos se superponen, bueno para diseños de cuadrícula- Elige según tu diseño;
closestCenteres el valor predeterminado más común
¿Por qué los IDs de elementos deben ser únicos en todos los contenedores en una configuración de múltiples contenedores?
- dnd-kit identifica elementos globalmente por su ID
- Los IDs duplicados en contenedores hacen que dnd-kit elija el contenedor incorrecto
- Usa UUIDs o prefija los IDs con el nombre del contenedor para garantizar la unicidad
¿Cómo manejo el rendimiento con cientos de elementos ordenables?
- Renderizar muchos hooks
useSortablecausa retrasos notables - Usa virtualización (p. ej.,
@tanstack/react-virtual) para renderizar solo elementos visibles - Solo envuelve elementos visibles con
useSortable - Reduce la frecuencia de actualización del sensor si es necesario
Relacionado
- React Patterns — Patrones de composición de componentes para DnD
- React Hooks — Hooks personalizados usados con dnd-kit
- Zustand — Gestión de estado externa para estado DnD complejo