React Drag and Drop with dnd-kit Guide (2026)
dnd-kit is the modern drag-and-drop library for React — it replaced react-beautiful-dnd as the community standard after react-beautiful-dnd was deprecated. dnd-kit is headless, composable, accessible by default and works with pointer events, touch and keyboard. This guide builds from simple draggables up to a full Kanban board.
Table of Contents
Setup and Core Concepts
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
dnd-kit has three layers:
- @dnd-kit/core —
DndContext,useDraggable,useDroppable, sensors - @dnd-kit/sortable —
SortableContext,useSortable,arrayMove - @dnd-kit/utilities —
CSS.Transform.toStringand other helpers
Basic Draggable and Droppable
import {
DndContext,
useDraggable,
useDroppable,
DragEndEvent,
} from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
function Draggable({ id, children }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.5 : 1,
cursor: 'grab',
}}
{...listeners} // Pointer/touch/keyboard event handlers
{...attributes} // ARIA attributes for accessibility
>
{children}
</div>
)
}
function Droppable({ id, children }) {
const { setNodeRef, isOver } = useDroppable({ id })
return (
<div
ref={setNodeRef}
style={{
background: isOver ? 'rgba(99,102,241,0.15)' : 'transparent',
border: '2px dashed',
borderColor: isOver ? '#6366f1' : '#e2e8f0',
borderRadius: 8,
padding: 16,
minHeight: 100,
transition: 'background 0.2s',
}}
>
{children}
</div>
)
}
function DragDropDemo() {
const [dropped, setDropped] = useState(false)
function handleDragEnd(event: DragEndEvent) {
if (event.over?.id === 'droppable') setDropped(true)
}
return (
<DndContext onDragEnd={handleDragEnd}>
{!dropped && <Draggable id="draggable">Drag me</Draggable>}
<Droppable id="droppable">
{dropped ? 'Dropped!' : 'Drop here'}
</Droppable>
</DndContext>
)
}
Sortable List
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
// Individual sortable item
function SortableItem({ id, label }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
zIndex: isDragging ? 1 : 0,
}}
className="flex items-center gap-3 p-3 bg-white border rounded-lg mb-2 shadow-sm"
{...attributes}
>
{/* Drag handle — only this area initiates drag */}
<span {...listeners} style={{ cursor: 'grab', touchAction: 'none' }}>⠿</span>
{label}
</div>
)
}
// Sortable list container
function SortableList() {
const [items, setItems] = useState([
{ id: '1', label: 'Item One' },
{ id: '2', label: 'Item Two' },
{ id: '3', label: 'Item Three' },
{ id: '4', label: 'Item Four' },
])
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.findIndex(i => i.id === active.id)
const newIndex = items.findIndex(i => i.id === over.id)
return arrayMove(items, oldIndex, newIndex)
})
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map(i => i.id)}
strategy={verticalListSortingStrategy}
>
{items.map(item => (
<SortableItem key={item.id} id={item.id} label={item.label} />
))}
</SortableContext>
</DndContext>
)
}
Kanban Board
import { useState } from 'react'
import { DndContext, DragOverEvent, DragEndEvent } from '@dnd-kit/core'
import { SortableContext } from '@dnd-kit/sortable'
type Card = { id: string; title: string; columnId: string }
type Column = { id: string; title: string }
const COLUMNS: Column[] = [
{ id: 'todo', title: 'To Do' },
{ id: 'in-progress', title: 'In Progress' },
{ id: 'done', title: 'Done' },
]
function KanbanBoard() {
const [cards, setCards] = useState<Card[]>([
{ id: 'c1', title: 'Design homepage', columnId: 'todo' },
{ id: 'c2', title: 'Build API', columnId: 'in-progress' },
{ id: 'c3', title: 'Write tests', columnId: 'todo' },
{ id: 'c4', title: 'Deploy to prod', columnId: 'done' },
])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over) return
const cardId = active.id as string
const overId = over.id as string
// Check if dropped over a column or another card
const overColumn = COLUMNS.find(c => c.id === overId)
const overCard = cards.find(c => c.id === overId)
const targetColumnId = overColumn?.id ?? overCard?.columnId
if (targetColumnId) {
setCards(cards => cards.map(c => c.id === cardId ? { ...c, columnId: targetColumnId } : c))
}
}
return (
<DndContext onDragEnd={handleDragEnd}>
<div className="flex gap-4">
{COLUMNS.map(column => {
const columnCards = cards.filter(c => c.columnId === column.id)
return (
<KanbanColumn key={column.id} column={column} cards={columnCards} />
)
})}
</div>
</DndContext>
)
}
function KanbanColumn({ column, cards }) {
const { setNodeRef, isOver } = useDroppable({ id: column.id })
return (
<div
ref={setNodeRef}
className="flex-1 min-h-64 rounded-xl p-4"
style={{ background: isOver ? 'rgba(99,102,241,0.08)' : 'rgba(17,24,39,0.6)' }}
>
<h3 className="font-semibold mb-3">{column.title} ({cards.length})</h3>
<SortableContext items={cards.map(c => c.id)}>
{cards.map(card => <KanbanCard key={card.id} card={card} />)}
</SortableContext>
</div>
)
}
Collision Detection
import {
closestCenter, // Best for sortable lists
closestCorners, // Best for Kanban (detects which corner is closest)
rectIntersection, // Overlap-based — good for free-form drag
pointerWithin, // Pointer must be inside the droppable
} from '@dnd-kit/core'
// For Kanban: closestCorners prevents incorrect column detection
<DndContext collisionDetection={closestCorners} />
// Custom collision — prefer items over containers
import { MeasuringStrategy } from '@dnd-kit/core'
<DndContext
collisionDetection={closestCorners}
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
/>
Accessibility
// dnd-kit announces drag state to screen readers automatically.
// Customize announcements:
import { Announcements } from '@dnd-kit/core'
const announcements: Announcements = {
onDragStart: ({ active }) => `Picked up card ${active.id}.`,
onDragOver: ({ active, over }) =>
over ? `Card ${active.id} is over column ${over.id}.` : `Card ${active.id} is no longer over a column.`,
onDragEnd: ({ active, over }) =>
over ? `Card ${active.id} dropped into column ${over.id}.` : `Card ${active.id} was dropped.`,
onDragCancel: ({ active }) => `Dragging ${active.id} was cancelled.`,
}
<DndContext accessibility={{ announcements }}>