React Drag and Drop Tutorial with dnd-kit: From Basics to Kanban
A hands-on React drag-and-drop tutorial using dnd-kit: from basics to a Kanban board, with accessibility, performance tips, and common pitfalls.
Image used for representation purposes only.
Overview
Drag and drop is more than a visual flourish—it’s an interaction pattern that can make complex tasks feel effortless. In React, you can build it from scratch with the HTML5 Drag and Drop API, but a dedicated library gives you better accessibility, smoother motion, and cross‑device consistency. In this tutorial, you’ll build from a minimal example to a sortable list and finally a Kanban‑style board using dnd-kit, with notes on alternatives, performance, and pitfalls.
What you’ll build
- A minimal draggable and droppable pair
- A keyboard-accessible sortable list (reorder by drag or arrow keys)
- A Kanban board with multiple columns, moving items within and across columns
Why dnd-kit?
Several React drag-and-drop libraries exist. Here’s a quick take:
- dnd-kit: Modern, modular, accessible. Offers granular primitives (sensors, collision detection, overlays) and a batteries-included sortable add-on.
- React DnD: Battle-tested on top of the HTML5 DnD backend; great for complex data flows and custom backends.
- @hello-pangea/dnd: A community-maintained fork of react-beautiful-dnd with great ergonomics for lists and boards.
We’ll use dnd-kit for its flexibility and excellent developer experience.
Prerequisites
- Basic React knowledge (hooks, state, props)
- Node.js and a React project (Vite, Next.js, or CRA)
Setup
Install the packages you’ll use:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/modifiers @dnd-kit/accessibility @dnd-kit/utilities
Key pieces you’ll import:
- @dnd-kit/core: DndContext, useDraggable, useDroppable, sensors
- @dnd-kit/sortable: SortableContext, useSortable, arrayMove, strategies
- @dnd-kit/modifiers: helpers to restrict movement
- @dnd-kit/utilities: CSS transforms
The absolute basics: one draggable, one droppable
This example shows how the building blocks fit together.
import React, {useState} from 'react';
import {DndContext, useDraggable, useDroppable} from '@dnd-kit/core';
import {CSS} from '@dnd-kit/utilities';
function Draggable() {
const {attributes, listeners, setNodeRef, transform, isDragging} = useDraggable({ id: 'draggable-1' });
const style = {
transform: transform ? CSS.Translate.toString(transform) : undefined,
opacity: isDragging ? 0.5 : 1,
padding: '12px 16px',
background: '#2563eb',
color: '#fff',
borderRadius: 8,
cursor: 'grab',
width: 160,
textAlign: 'center',
};
return (
<button ref={setNodeRef} {...listeners} {...attributes} style={style}>
Drag me
</button>
);
}
function Droppable({children}) {
const {isOver, setNodeRef} = useDroppable({ id: 'drop-zone' });
const style = {
height: 140,
width: 260,
border: '2px dashed #94a3b8',
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: isOver ? '#e0f2fe' : 'transparent',
};
return <div ref={setNodeRef} style={style}>{children}</div>;
}
export default function BasicDnD() {
const [dropped, setDropped] = useState(false);
return (
<DndContext onDragEnd={({over}) => setDropped(Boolean(over && over.id === 'drop-zone'))}>
<div style={{display: 'flex', gap: 24, alignItems: 'center'}}>
<Draggable />
<Droppable>{dropped ? 'Dropped!' : 'Drop here'}</Droppable>
</div>
</DndContext>
);
}
What’s happening:
- DndContext wires up the drag-and-drop system.
- useDraggable returns listeners/attributes and a transform you render as CSS.
- useDroppable tells you when an item is over the zone.
- onDragEnd checks whether the draggable is over the droppable and updates state.
Build a sortable list
Next, you’ll create a list that reorders by dragging. dnd-kit’s sortable utilities handle item transforms, keyboard interactions, and collision detection.
import React, {useState} from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
arrayMove,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {sortableKeyboardCoordinates} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';
import {restrictToVerticalAxis} from '@dnd-kit/modifiers';
function SortableItem({id}) {
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: '12px 16px',
margin: '6px 0',
background: isDragging ? '#fde047' : '#f1f5f9',
border: '1px solid #e2e8f0',
borderRadius: 8,
cursor: 'grab',
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{id}
</div>
);
}
export default function SortableList() {
const [items, setItems] = useState(['Alpha', 'Bravo', 'Charlie', 'Delta']);
const sensors = useSensors(
useSensor(PointerSensor, {activationConstraint: {distance: 4}}),
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates})
);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={({active, over}) => {
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.indexOf(active.id);
const newIndex = prev.indexOf(over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((id) => <SortableItem key={id} id={id} />)}
</SortableContext>
</DndContext>
);
}
Highlights:
- SortableContext receives the current item order and a strategy (vertical list in this case).
- arrayMove reorders state immutably.
- Sensors make it work with mouse, touch, and keyboard.
From list to Kanban board
To move items within and across columns, you’ll track columns as a map of IDs to item arrays, and implement logic to relocate items.
import React, {useMemo, useState} from 'react';
import {
DndContext,
closestCorners,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';
import {restrictToWindowEdges} from '@dnd-kit/modifiers';
const initial = {
backlog: ['Wireframes', 'User stories'],
todo: ['Build auth', 'Set up CI'],
doing: ['API contract'],
done: ['Design tokens'],
};
function Card({id, title}) {
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: '10px 12px',
background: isDragging ? '#e9d5ff' : '#fff',
border: '1px solid #e2e8f0',
borderRadius: 8,
boxShadow: isDragging ? '0 6px 20px rgba(0,0,0,0.15)' : 'none',
cursor: 'grab',
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{title}
</div>
);
}
function Column({id, title, items}) {
return (
<div style={{
width: 260,
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 12,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}>
<h3 style={{margin: '6px 0 10px', fontSize: 16}}>{title}</h3>
<SortableContext items={items} strategy={rectSortingStrategy}>
{items.map((card) => (
<Card key={card} id={`${id}:${card}`} title={card} />
))}
</SortableContext>
</div>
);
}
export default function Kanban() {
const [columns, setColumns] = useState(initial);
const [activeId, setActiveId] = useState(null);
const sensors = useSensors(
useSensor(PointerSensor, {activationConstraint: {distance: 5}}),
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates})
);
// Flattened list of droppable/drag ids for the context
const droppableIds = useMemo(() => Object.keys(columns), [columns]);
const allItemIds = useMemo(
() => droppableIds.flatMap((colId) => columns[colId].map((title) => `${colId}:${title}`)),
[columns, droppableIds]
);
function findContainer(id) {
// id could be a column id or a card id formatted as `column:cardTitle`
if (id in columns) return id;
const [col] = id.split(':');
return col;
}
function handleDragStart({active}) {
setActiveId(active.id);
}
function handleDragOver({active, over}) {
if (!over) return;
const activeCol = findContainer(active.id);
const overCol = findContainer(over.id);
if (!activeCol || !overCol || activeCol === overCol) return;
setColumns((prev) => {
const activeCardTitle = active.id.split(':').slice(1).join(':');
const fromItems = [...prev[activeCol]];
const toItems = [...prev[overCol]];
const fromIndex = fromItems.indexOf(activeCardTitle);
if (fromIndex === -1) return prev;
fromItems.splice(fromIndex, 1);
// Determine the index in target column
let overIndex = toItems.length;
if (over.id.includes(':')) {
const overTitle = over.id.split(':').slice(1).join(':');
overIndex = Math.max(0, toItems.indexOf(overTitle));
}
toItems.splice(overIndex, 0, activeCardTitle);
return {...prev, [activeCol]: fromItems, [overCol]: toItems};
});
}
function handleDragEnd({active, over}) {
setActiveId(null);
if (!over) return;
const activeCol = findContainer(active.id);
const overCol = findContainer(over.id);
const activeTitle = active.id.split(':').slice(1).join(':');
const overTitle = over.id.split(':').slice(1).join(':');
if (activeCol === overCol && activeTitle && overTitle) {
// Reorder within the same column
setColumns((prev) => {
const colItems = prev[activeCol];
const oldIndex = colItems.indexOf(activeTitle);
const newIndex = colItems.indexOf(overTitle);
return {...prev, [activeCol]: arrayMove(colItems, oldIndex, newIndex)};
});
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
modifiers={[restrictToWindowEdges]}
>
<div style={{display: 'flex', gap: 16, alignItems: 'flex-start'}}>
{Object.entries(columns).map(([id, items]) => (
<div key={id}>
<Column id={id} title={id.toUpperCase()} items={items} />
</div>
))}
</div>
<DragOverlay dropAnimation={{duration: 200}}>
{activeId ? (
<div style={{
padding: '10px 12px',
background: '#fdf2f8',
border: '1px solid #fbcfe8',
borderRadius: 8,
boxShadow: '0 10px 30px rgba(0,0,0,0.2)'
}}>
{activeId.split(':').slice(1).join(':')}
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
Notes:
- Items are identified as columnId:title for simplicity. In production, use stable IDs (UUIDs) rather than titles.
- handleDragOver moves items across columns in real time for a lively preview; handleDragEnd finalizes in-column reorder.
- DragOverlay renders a “lifted” clone with custom styling.
Accessibility and sensors
dnd-kit ships with a KeyboardSensor and ARIA live region helpers. Best practices:
- Always provide keyboard support using KeyboardSensor and sortableKeyboardCoordinates.
- Ensure focus outline remains visible while dragging via CSS.
- Use semantic elements (button for draggable controls when appropriate) and clear color contrast.
Collision detection strategies
- closestCenter: Good for lists where pointer alignment is central.
- closestCorners: Helps when targets are various sizes or arranged in a grid.
- Custom strategies: You can write your own to handle edge cases (e.g., large drop zones or nested lists).
Try different strategies in DndContext’s collisionDetection prop to match your layout.
State management patterns
For larger apps, colocating state in a parent works, but consider:
- useReducer or Zustand for predictable updates.
- Keep derived UI (like transforms) out of global stores; store only the minimal source of truth.
- When moving between lists, write small, pure helper functions to locate, remove, and insert items—easy to test and reuse.
Example helper for safe moves:
type Board = Record<string, string[]>;
export function moveBetween(board: Board, from: string, to: string, title: string, index: number) {
const next: Board = {...board};
const fromItems = [...next[from]];
const toItems = [...next[to]];
const i = fromItems.indexOf(title);
if (i === -1) return board;
fromItems.splice(i, 1);
toItems.splice(index, 0, title);
next[from] = fromItems;
next[to] = toItems;
return next;
}
Modifiers: constrain movement
Modifiers let you limit dragging to a direction or region:
- restrictToVerticalAxis for list reordering
- restrictToHorizontalAxis for horizontal carousels
- restrictToParentElement or restrictToWindowEdges to keep items on-screen
- snapCenterToCursor for a different drag feel
Apply them via the modifiers prop on DndContext.
Styling and animation tips
- Prefer transforms to adjust position (GPU-accelerated) and avoid layout thrash.
- Set will-change: transform on draggable items during drag.
- Use transition from useSortable for smooth drop animations.
- Add a dragging class to tweak z-index or pointer-events.
Example CSS snippet:
.card {
transition: transform 200ms ease, box-shadow 200ms ease;
}
.card[data-dragging='true'] {
z-index: 10;
box-shadow: 0 8px 24px rgba(0,0,0,0.18);
}
Persisting order
Persist to localStorage or your backend on every drop:
useEffect(() => {
localStorage.setItem('board', JSON.stringify(columns));
}, [columns]);
On load, check for saved data before using initial state.
Performance: scaling to thousands of items
- Memoize item rows with React.memo and stable props.
- Avoid re-rendering the entire board; split columns into separate components.
- Consider virtualization (react-window or react-virtualized) for very long lists. You’ll need to coordinate item heights with the virtualization library.
- Keep the list of items passed to SortableContext minimal and stable between renders.
Common pitfalls and fixes
- Jumpy layout: Ensure each item has a stable height/width to avoid reflow during drag.
- Incorrect reorder: Always use stable IDs unrelated to titles or indices.
- Dropping outside zones: Use modifiers like restrictToWindowEdges or add a safe fallback in onDragEnd if over is null.
- Touch devices not initiating drag: Add a small activationConstraint distance in PointerSensor to avoid accidental drags.
- Overlapping droppables: Try closestCorners or tweak your item padding/margins.
Testing strategies
- Unit test your move helpers and reducers (pure functions) thoroughly.
- For UI, simulate pointer events at a higher level or use component tests to assert state changes after onDragEnd.
- Snapshot the DOM before/after a drag to verify overlays and aria-live announcements if you add them.
Alternatives with quick examples
If dnd-kit isn’t a fit, here are brief starting points:
- React DnD (HTML5 backend):
npm install react-dnd react-dnd-html5-backend
import {DndProvider, useDrag, useDrop} from 'react-dnd';
import {HTML5Backend} from 'react-dnd-html5-backend';
function DraggableBox() {
const [{isDragging}, drag] = useDrag(() => ({ type: 'BOX', item: {id: 1}, collect: (m) => ({isDragging: m.isDragging()}) }));
return <div ref={drag} style={{opacity: isDragging ? 0.5 : 1}}>Drag me</div>;
}
function DropZone() {
const [, drop] = useDrop(() => ({ accept: 'BOX', drop: (item) => console.log('dropped', item) }));
return <div ref={drop} style={{height: 100, border: '1px dashed #94a3b8'}}>Drop here</div>;
}
export default function App(){
return (
<DndProvider backend={HTML5Backend}>
<DraggableBox />
<DropZone />
</DndProvider>
);
}
- @hello-pangea/dnd (list ergonomics):
npm install @hello-pangea/dnd
import {DragDropContext, Droppable, Draggable} from '@hello-pangea/dnd';
function List() {
const [items, setItems] = React.useState(['A','B','C']);
return (
<DragDropContext onDragEnd={({source, destination}) => {
if (!destination) return;
const next = [...items];
const [moved] = next.splice(source.index, 1);
next.splice(destination.index, 0, moved);
setItems(next);
}}>
<Droppable droppableId="list">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{items.map((it, i) => (
<Draggable key={it} draggableId={it} index={i}>
{(p, snapshot) => (
<div ref={p.innerRef} {...p.draggableProps} {...p.dragHandleProps} style={{...p.draggableProps.style, background: snapshot.isDragging ? '#fde68a' : '#fff'}}>
{it}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}
Deployment checklist
- Ensure keyboard interactions work (Tab to item, Space/Enter to lift, arrow keys to move, Space/Enter to drop with dnd-kit’s sortable).
- Verify on touch and mouse devices.
- Persist state and gracefully handle reloads.
- Minify and tree-shake: dnd-kit’s modularity helps keep bundles lean.
Wrapping up
You’ve seen how to go from a minimal draggable to a full Kanban board using dnd-kit. With sensors, collision strategies, overlays, and modifiers, you can adapt drag-and-drop to almost any UI. For production, focus on stable IDs, accessibility, and performance. From here, consider virtualized lists, nested droppables, and custom collision detection to meet your app’s exact needs.
Related Posts
React useMemo and useCallback: A Practical Optimization Guide
Practical guide to React’s useMemo and useCallback: when to use them, pitfalls to avoid, patterns, and profiling tips for faster apps.
React Error Boundaries: A Complete Implementation Guide
Implement robust React error boundaries with TypeScript examples, reset strategies, logging, Suspense interplay, testing, and accessibility.
React Accessibility: Practical ARIA Best Practices
A practical React guide to ARIA—when to use it, when not to, plus patterns for focus, labels, widgets, and testing.