dnd-kit - Lightweight, accessible drag and drop for React with sortable lists and multiple containers
Recipe
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>
);
}When to reach for this: You need drag-and-drop reordering, sortable lists, kanban boards, or draggable UI elements with keyboard accessibility.
Working Example
// 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>
);
}What this demonstrates:
- Multi-container drag and drop (kanban-style columns)
DragOverlayfor a custom drag preview- Cross-container item movement via
onDragOver - Within-container reordering via
onDragEnd - Activation constraint to prevent accidental drags
- Keyboard accessibility with
KeyboardSensor
Deep Dive
How It Works
DndContextis the root provider that manages drag state, sensors, and collision detection- Sensors (
PointerSensor,KeyboardSensor,TouchSensor) determine how drag interactions are initiated and tracked useSortablecombinesuseDraggableanduseDroppableinto a single hook for sortable itemsSortableContexttracks the order of items and provides the sorting strategy (vertical, horizontal, or grid)- Collision detection algorithms (
closestCenter,closestCorners,rectIntersection) determine which droppable target is active during a drag DragOverlayrenders a floating element that follows the cursor, separate from the DOM position of the dragged itemarrayMoveis a utility that reorders an array by moving an element from one index to another
Variations
Horizontal sortable list:
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>Drag handle (only part of the item is draggable):
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>
);
}Restricting drag axis:
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
<DndContext modifiers={[restrictToVerticalAxis]}>
{/* ... */}
</DndContext>TypeScript Notes
DragEndEvent,DragStartEvent,DragOverEventprovide typedactiveandoverobjectsactive.idandover.idareUniqueIdentifier(string or number)- Cast with
as stringwhen your IDs are known to be strings useSortablereturns a typed object; destructure only the properties you need
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;
}Gotchas
-
Items jump on drag start — The dragged item's space collapses, causing layout shift. Fix: Use
DragOverlayfor the visual preview and keep the original item in place with reduced opacity. -
Accidental drags on click — Single clicks trigger drag events. Fix: Add
activationConstraint: { distance: 8 }toPointerSensorto require 8px of movement before activating. -
IDs must be unique across all containers — If two items in different columns share an ID, dnd-kit cannot determine the correct container. Fix: Use globally unique IDs (e.g., UUIDs) for all draggable items.
-
SSR hydration mismatch — dnd-kit uses DOM measurements that don't exist on the server. Fix: Ensure the component is marked
"use client"and wrap in a client-only boundary if needed. -
Performance with many items — Rendering hundreds of sortable items causes lag. Fix: Use virtualization (e.g.,
react-virtual) and only wrap visible items inuseSortable.
Alternatives
| Library | Best For | Trade-off |
|---|---|---|
| dnd-kit | Modern, accessible, composable DnD | Requires more setup for complex cases |
| react-beautiful-dnd | Simple sortable lists | Deprecated, no longer maintained |
| react-dnd | Complex custom DnD interactions | Steeper learning curve, HTML5 backend limitations |
| @hello-pangea/dnd | Drop-in react-beautiful-dnd replacement | Fork, community-maintained |
| Native HTML drag | Simple drag without libraries | No keyboard accessibility, limited control |
FAQs
What are the core packages I need to install for a sortable list?
@dnd-kit/core-- providesDndContext, sensors, and collision detection@dnd-kit/sortable-- providesSortableContext,useSortable, andarrayMove@dnd-kit/utilities-- providesCSS.Transform.toStringfor applying transforms- Install all three for a sortable list:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
What is the role of DndContext and why is it required?
DndContextis the root provider that manages all drag-and-drop state- It coordinates sensors, collision detection, and event handlers
- All draggable and droppable elements must be descendants of
DndContext - It provides
onDragStart,onDragOver, andonDragEndcallbacks
How does useSortable differ from using useDraggable and useDroppable separately?
useSortablecombines both hooks into one for items that are both draggable and droppable- It handles the sorting transform and transition automatically
- Use
useDraggable+useDroppableseparately only when items are draggable but not sortable
Gotcha: Why do my items jump around when I start dragging?
- The dragged item's space collapses when removed from flow, causing layout shift
- Use
DragOverlayto render a floating preview that follows the cursor - Keep the original item in place with
opacity: 0.5while dragging DragOverlayrenders outside the normal DOM flow and avoids layout shifts
How do I prevent accidental drags when clicking on items?
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);The distance: 8 constraint requires 8px of movement before a drag activates.
How do I implement a drag handle so only part of the item is draggable?
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>
);
}Apply attributes and listeners to the handle element, not the container.
How do I restrict dragging to only the vertical axis?
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
<DndContext modifiers={[restrictToVerticalAxis]}>
{/* ... */}
</DndContext>How do I type the DragEndEvent handler in 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 is string | number. Cast with as string if your IDs are always strings.
Gotcha: Why does dnd-kit break with SSR or cause hydration mismatches?
- dnd-kit uses DOM measurements that do not exist during server-side rendering
- Ensure components using dnd-kit are marked
"use client" - If you see hydration errors, wrap the dnd-kit component in a client-only boundary
What collision detection algorithms are available and when should I use each?
closestCenter-- best for single-container sortable listsclosestCorners-- best for multi-container setups (kanban boards)rectIntersection-- triggers when rectangles overlap, good for grid layouts- Choose based on your layout;
closestCenteris the most common default
Why must item IDs be unique across all containers in a multi-container setup?
- dnd-kit identifies items globally by their ID
- Duplicate IDs across containers cause dnd-kit to pick the wrong container
- Use UUIDs or prefix IDs with the container name to guarantee uniqueness
How do I handle performance with hundreds of sortable items?
- Rendering many
useSortablehooks causes noticeable lag - Use virtualization (e.g.,
@tanstack/react-virtual) to render only visible items - Only wrap visible items with
useSortable - Reduce the sensor update frequency if needed
Related
- React Patterns — Component composition patterns for DnD
- React Hooks — Custom hooks used with dnd-kit
- Zustand — External state management for complex DnD state