Build a React Kanban Board Component with Drag‑and‑Drop (dnd‑kit)
Step-by-step tutorial to build a reusable React Kanban board with drag-and-drop using dnd-kit, plus persistence, accessibility, and performance tips.
Image used for representation purposes only.
Overview
Kanban is a simple, visual way to manage work by moving cards through columns like “To do,” “In progress,” and “Done.” In this tutorial you’ll build a reusable Kanban board component in React with modern, accessible drag‑and‑drop powered by dnd‑kit. We’ll cover project setup, data modeling, interactive features (add, edit, delete, reorder), persistence with localStorage, and patterns to package the board as a shareable component.
What you’ll build:
- A React Kanban board with multiple columns
- Drag‑and‑drop cards within and across columns
- Keyboard‑accessible interactions
- Local persistence and clean, reusable component APIs
Estimated time: 60–90 minutes.
Prerequisites
- Familiarity with React hooks (useState, useEffect, useMemo)
- Node 18+ and npm or pnpm
- Basic CSS knowledge
Project setup
You can start with any React scaffold. Here’s a quick Vite setup:
# JavaScript template
npm create vite@latest react-kanban -- --template react
cd react-kanban
npm i
# Drag and drop + ids
npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/modifiers nanoid
# optional: type checking
# npm i -D typescript @types/react @types/react-dom
Update src/main.jsx and src/App.jsx as needed; we’ll focus on the Kanban components.
Data model
A small, normalized model keeps operations predictable:
// src/kanban/data.js
export const initialData = {
columns: {
todo: { id: 'todo', title: 'To do', cardIds: ['a', 'b'] },
doing: { id: 'doing', title: 'In progress', cardIds: [] },
done: { id: 'done', title: 'Done', cardIds: [] }
},
cards: {
a: { id: 'a', title: 'Set up project', description: '' },
b: { id: 'b', title: 'Build columns', description: '' }
},
columnOrder: ['todo', 'doing', 'done']
};
Why this shape?
- Fast lookups by id
- Reordering is just moving ids inside column.cardIds
- Easy to persist and diff
KanbanBoard component structure
We’ll break the board into small components.
- KanbanBoard: owns state, sensors, and top‑level DnD handlers
- Column: renders a column header and a sortable list of Card components
- Card: draggable/sortable item
- AddCardForm: small input for new tasks
File structure:
src/
kanban/
KanbanBoard.jsx
Column.jsx
Card.jsx
AddCardForm.jsx
data.js
App.jsx
main.jsx
styles.css
Styling (minimal, framework‑agnostic)
Add a tiny stylesheet for a clean look.
/* src/styles.css */
:root {
--bg: #0b0c10; --panel: #111317; --muted: #6b7280; --text: #e5e7eb;
--accent: #61dafb; --accent-2: #4ade80; --border: #1f2937;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui; }
.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 24px; }
.column { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; display: flex; flex-direction: column; }
.column h3 { margin: 12px; font-size: 14px; letter-spacing: .02em; text-transform: uppercase; color: var(--muted); }
.card { background: #161a22; border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin: 8px 12px; box-shadow: 0 1px 0 #0004; }
.card.dragging { opacity: .6; outline: 2px dashed var(--accent); }
.card:focus { outline: 2px solid var(--accent); }
.placeholder { margin: 8px 12px; padding: 12px; color: var(--muted); border: 1px dashed var(--border); border-radius: 10px; text-align: center; }
.column-footer { margin: 8px 12px 12px; }
.add-form { display: flex; gap: 8px; }
.add-form input { flex: 1; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: #0f1218; color: var(--text); }
.add-form button { padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--accent); color: #0b0c10; font-weight: 600; }
.toolbar { display: flex; gap: 8px; margin: 0 12px 12px; }
.toolbar button { background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 6px 8px; border-radius: 8px; }
Import it once in src/main.jsx or src/App.jsx:
import './styles.css';
Card component (draggable)
// src/kanban/Card.jsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
export default function Card({ id, title, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition
};
return (
<div
ref={setNodeRef}
className={`card ${isDragging ? 'dragging' : ''}`}
style={style}
tabIndex={0}
{...attributes}
{...listeners}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong>{title}</strong>
{onDelete && (
<button aria-label="Delete card" onClick={() => onDelete(id)} className="icon-btn">✕</button>
)}
</div>
</div>
);
}
Column component (sortable list)
// src/kanban/Column.jsx
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import Card from './Card';
export default function Column({ column, cards, onDeleteCard, footer }) {
return (
<section className="column" role="region" aria-labelledby={`col-${column.id}`}
data-column-id={column.id}
>
<h3 id={`col-${column.id}`}>{column.title}</h3>
<SortableContext items={cards.map(c => c.id)} strategy={verticalListSortingStrategy}>
{cards.length === 0 && <div className="placeholder">No cards</div>}
{cards.map(card => (
<Card key={card.id} id={card.id} title={card.title} onDelete={onDeleteCard} />
))}
</SortableContext>
{footer}
</section>
);
}
AddCardForm component
// src/kanban/AddCardForm.jsx
import { useState } from 'react';
export default function AddCardForm({ onAdd }) {
const [value, setValue] = useState('');
return (
<form className="add-form" onSubmit={e => { e.preventDefault(); if (!value.trim()) return; onAdd(value.trim()); setValue(''); }}>
<input aria-label="New card title" placeholder="New task…" value={value} onChange={e => setValue(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}
KanbanBoard (state + drag handlers)
We’ll wire up sensors, drag events, and persistence. We support moving items within and across columns.
// src/kanban/KanbanBoard.jsx
import { useEffect, useMemo, useState } from 'react';
import { DndContext, useSensor, useSensors, PointerSensor, KeyboardSensor, closestCorners } from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { nanoid } from 'nanoid';
import Column from './Column';
import AddCardForm from './AddCardForm';
import { initialData as seed } from './data';
const STORAGE_KEY = 'react-kanban:v1';
function load() {
try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : seed; } catch { return seed; }
}
function save(data) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
}
export default function KanbanBoard() {
const [data, setData] = useState(load);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
useEffect(() => save(data), [data]);
const getColumnByCardId = (cardId) => {
return Object.values(data.columns).find(col => col.cardIds.includes(cardId));
};
const cardsByColumn = useMemo(() => {
const map = {};
for (const colId of data.columnOrder) {
const col = data.columns[colId];
map[colId] = col.cardIds.map(id => data.cards[id]).filter(Boolean);
}
return map;
}, [data]);
function addCard(columnId, title) {
const id = nanoid(6);
setData(prev => ({
...prev,
cards: { ...prev.cards, [id]: { id, title } },
columns: {
...prev.columns,
[columnId]: { ...prev.columns[columnId], cardIds: [id, ...prev.columns[columnId].cardIds] }
}
}));
}
function deleteCard(cardId) {
setData(prev => {
const { [cardId]: _, ...restCards } = prev.cards;
const columns = Object.fromEntries(Object.entries(prev.columns).map(([cid, col]) => [cid, { ...col, cardIds: col.cardIds.filter(id => id !== cardId) }]));
return { ...prev, cards: restCards, columns };
});
}
function handleDragStart() {}
function handleDragOver(event) {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
const fromCol = getColumnByCardId(activeId);
let toCol = getColumnByCardId(overId);
// If hovering over a column container (not a card), infer from data attribute
if (!toCol && over?.data?.current?.type === 'column') {
toCol = data.columns[over.data.current.columnId];
}
if (!fromCol || !toCol || fromCol.id === toCol.id) return;
setData(prev => {
const fromIds = [...prev.columns[fromCol.id].cardIds];
const toIds = [...prev.columns[toCol.id].cardIds];
const fromIndex = fromIds.indexOf(activeId);
if (fromIndex === -1) return prev;
fromIds.splice(fromIndex, 1);
// Insert at the nearest index of the hovered card; fallback to top
const overIndex = toIds.indexOf(overId);
const insertAt = overIndex >= 0 ? overIndex : 0;
toIds.splice(insertAt, 0, activeId);
return {
...prev,
columns: {
...prev.columns,
[fromCol.id]: { ...prev.columns[fromCol.id], cardIds: fromIds },
[toCol.id]: { ...prev.columns[toCol.id], cardIds: toIds }
}
};
});
}
function handleDragEnd(event) {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
const fromCol = getColumnByCardId(activeId);
const toCol = getColumnByCardId(overId) || (over?.data?.current?.type === 'column' ? data.columns[over.data.current.columnId] : null);
if (!fromCol || !toCol) return;
if (fromCol.id === toCol.id) {
// Reorder within same column
setData(prev => {
const ids = [...prev.columns[fromCol.id].cardIds];
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
if (oldIndex === -1) return prev;
ids.splice(oldIndex, 1);
ids.splice(newIndex === -1 ? 0 : newIndex, 0, activeId);
return { ...prev, columns: { ...prev.columns, [fromCol.id]: { ...prev.columns[fromCol.id], cardIds: ids } } };
});
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="board">
{data.columnOrder.map((colId) => {
const col = data.columns[colId];
const footer = (
<div className="column-footer">
<AddCardForm onAdd={(title) => addCard(col.id, title)} />
</div>
);
return (
<Column
key={col.id}
column={col}
cards={cardsByColumn[col.id]}
onDeleteCard={deleteCard}
footer={footer}
/>
);
})}
</div>
</DndContext>
);
}
Making columns droppable targets
The DndContext above handles cards, but when you drop into an empty column you want it to work too. A simple pattern is to add a “column droppable” overlay. With dnd‑kit, you can pass custom data to droppables. For brevity, the Column component above doesn’t create a droppable root. If you want empty‑column drops, wrap the column body with a Droppable from @dnd-kit/core and set data.current = { type: ‘column’, columnId } so handleDragOver/End can detect it (we already check for that in the handlers). This keeps behavior intuitive even when columns are empty.
Minimal example snippet:
// inside Column.jsx
import { useDroppable } from '@dnd-kit/core';
export default function Column({ column, cards, onDeleteCard, footer }) {
const { setNodeRef } = useDroppable({ id: `drop-${column.id}`, data: { type: 'column', columnId: column.id } });
return (
<section className="column" ref={setNodeRef} role="region" aria-labelledby={`col-${column.id}`}>
{/* ...rest unchanged */}
</section>
);
}
Wiring it into App
// src/App.jsx
import KanbanBoard from './kanban/KanbanBoard';
import './styles.css';
export default function App() {
return (
<>
<div className="toolbar">
<button onClick={() => localStorage.clear() || location.reload()}>Reset demo</button>
</div>
<KanbanBoard />
</>
);
}
Accessibility notes
- KeyboardSensor adds arrow‑key sorting for focused cards (Enter/Space to pick up; arrows to move; Enter/Space to drop).
- Give focus styles (already in CSS) and clear ARIA labels on controls.
- Maintain color contrast for dark themes.
Turning the board into a reusable component
Right now KanbanBoard owns state. To reuse it, expose a controlled API:
Props to consider:
- columns: array or normalized shape
- cards: by id
- onChange(nextState): lift state updates to parent
- renderCard(card): custom rendering
- getCardId, getColumnId: id accessors for flexibility
- allowAdd, allowDelete, onAdd, onDelete: feature toggles and callbacks
A thin adapter can map your domain objects (e.g., tickets with priority, assignees) to the board shape.
Example controlled signature stub:
export function KanbanBoard({ state, onChange, renderCard }) { /* ... */ }
Enhancements you can add next
- Column reordering: use another SortableContext for columns
- Multi‑select drag: track selected ids in keyboard mode
- Virtualization for long columns: integrate react‑virtual‑ized or react‑window
- Server sync: persist to an API and reconcile optimistic updates
- Card details modal: edit description, labels, assignees
- Filtering and swimlanes: group by assignee or label
Performance tips
- Memoize derived structures like cardsByColumn with useMemo
- Wrap Card with React.memo if renderCard becomes heavy
- Batch state updates; keep ids arrays small and flat
- Lazy‑load heavy modals/details
Troubleshooting
- Cards snap back? Ensure ids are stable and unique (use nanoid). Check that SortableContext items match rendered card ids.
- Drops into empty columns don’t work? Ensure the column surface is droppable and your drag handlers detect column targets.
- Janky dragging? Add activationConstraint to PointerSensor (distance or delay), and avoid expensive re‑renders by memoizing children.
Conclusion
You now have a modern, accessible Kanban board in React using dnd‑kit. The normalized data model keeps updates simple, and the component breakdown makes it easy to extend. With a controlled API and a couple of niceties like localStorage and keyboard support, you’re ready to integrate this board into real projects—or publish it as a reusable package.
Related Posts
React AI Chatbot Tutorial: Build a Streaming Chat UI with OpenAI and Node.js
Build a streaming React AI chatbot with a secure Node proxy using OpenAI’s Responses API. Code, SSE streaming, model tips, and production guidance.
Building an Accessible React Range Slider (Single and Two‑Thumb)
Build an accessible React range slider with ARIA, keyboard support, screen reader clarity, and a two‑thumb range implementation with robust form integration.
Build a React Multi-Step Form Wizard with React Hook Form and Zod
Build a robust React multi-step form wizard with React Hook Form and Zod: step-level validation, persistence, and accessibility. Includes full code.