Kanban Board in React: Drag-and-Drop Task Management UI
Build a fully functional drag-and-drop Kanban board in React with @dnd-kit, Tailwind CSS, and typed column state — no bloated libraries, just clean component code.
Why Building Your Own Kanban Board Is Worth It
Honestly, most off-the-shelf Kanban packages come with a mountain of opinions baked in — their own state shape, their own CSS reset, their own drag library that conflicts with yours. By the time you've patched the API to fit your data model, you've spent more time fighting the library than you would've spent building from scratch.
The React ecosystem in 2026 gives you exactly the primitives you need: @dnd-kit/core for pointer-and-keyboard-friendly drag-and-drop, Tailwind v4.0.2 for layout, and standard React state for column management. That's the whole stack. No 300kb bundle, no hidden abstractions.
This article walks you through building a real Kanban board — columns, cards, drag between columns, accessible keyboard support — the kind you'd actually ship. If you just need a quick interactive card layout without drag mechanics, check out the cards stack component first to get a feel for card structure before adding DnD on top.
Setting Up @dnd-kit: Installation and Core Concepts
Install the two packages you actually need — nothing more.
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities@dnd-kit/core gives you DndContext, DragOverlay, sensors, and collision detection. @dnd-kit/sortable adds the useSortable hook and SortableContext for items that can reorder within a list. The utilities package has arrayMove — a pure function for reordering arrays without mutation. That's genuinely all you need for a full Kanban.
The mental model is: DndContext wraps your whole board and fires onDragStart, onDragOver, and onDragEnd events with payload data you define. Each card gets a unique id string. You map that id to your column state inside the event handlers. The library doesn't touch your state — you own it completely.
One thing that trips people up: @dnd-kit sensors need to be configured explicitly. The PointerSensor covers mouse and touch; the KeyboardSensor makes the board accessible. You'll always want both.
Typing Your Kanban State in TypeScript
Before writing a single component, define the data shape. This pays off immediately — TypeScript will catch column-id mismatches and undefined card lookups before they become runtime bugs.
// types/kanban.ts
export type ColumnId = 'todo' | 'in-progress' | 'done';
export interface KanbanCard {
id: string;
title: string;
description?: string;
columnId: ColumnId;
priority: 'low' | 'medium' | 'high';
}
export interface KanbanColumn {
id: ColumnId;
title: string;
cards: KanbanCard[];
}
export type BoardState = Record<ColumnId, KanbanColumn>;Keeping columnId on the card itself (not just inferred from position) makes the onDragEnd handler trivial — you always know where a card came from without scanning every column. It also makes serialisation to a backend or localStorage straightforward.
For initial state you can use a plain object literal or a factory function. A factory is nicer if you'll be generating columns dynamically from an API response, but a literal is fine for fixed columns like Todo / In Progress / Done.
Building the DndContext and Drag Handlers
Here's the core board component with full drag-between-columns logic. The onDragOver handler gives you the snappy visual preview as you drag; onDragEnd commits the state change.
// KanbanBoard.tsx
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
closestCorners,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useState } from 'react';
import { BoardState, ColumnId, KanbanCard } from '../types/kanban';
import { KanbanColumnComponent } from './KanbanColumn';
import { CardItem } from './CardItem';
const INITIAL_STATE: BoardState = {
todo: { id: 'todo', title: 'To Do', cards: [
{ id: 'c1', title: 'Design system audit', columnId: 'todo', priority: 'high' },
{ id: 'c2', title: 'Write release notes', columnId: 'todo', priority: 'low' },
]},
'in-progress': { id: 'in-progress', title: 'In Progress', cards: [
{ id: 'c3', title: 'Build Kanban component', columnId: 'in-progress', priority: 'high' },
]},
done: { id: 'done', title: 'Done', cards: [] },
};
export function KanbanBoard() {
const [board, setBoard] = useState<BoardState>(INITIAL_STATE);
const [activeCard, setActiveCard] = useState<KanbanCard | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function findColumnOfCard(cardId: string): ColumnId | null {
for (const colId of Object.keys(board) as ColumnId[]) {
if (board[colId].cards.some((c) => c.id === cardId)) return colId;
}
return null;
}
function onDragStart({ active }: DragStartEvent) {
const colId = findColumnOfCard(active.id as string);
if (!colId) return;
setActiveCard(board[colId].cards.find((c) => c.id === active.id) ?? null);
}
function onDragOver({ active, over }: DragOverEvent) {
if (!over) return;
const sourceCol = findColumnOfCard(active.id as string);
const destCol = (over.data.current?.sortable?.containerId ?? over.id) as ColumnId;
if (!sourceCol || !destCol || sourceCol === destCol) return;
setBoard((prev) => {
const card = prev[sourceCol].cards.find((c) => c.id === active.id)!;
const updated = { ...card, columnId: destCol };
return {
...prev,
[sourceCol]: {
...prev[sourceCol],
cards: prev[sourceCol].cards.filter((c) => c.id !== active.id),
},
[destCol]: {
...prev[destCol],
cards: [...prev[destCol].cards, updated],
},
};
});
}
function onDragEnd({ active, over }: DragEndEvent) {
setActiveCard(null);
if (!over || active.id === over.id) return;
const colId = findColumnOfCard(active.id as string);
if (!colId) return;
setBoard((prev) => {
const cards = prev[colId].cards;
const oldIndex = cards.findIndex((c) => c.id === active.id);
const newIndex = cards.findIndex((c) => c.id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return {
...prev,
[colId]: { ...prev[colId], cards: arrayMove(cards, oldIndex, newIndex) },
};
});
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<div className="flex gap-6 p-6 overflow-x-auto min-h-screen bg-neutral-950">
{(Object.values(board) as KanbanColumn[]).map((col) => (
<KanbanColumnComponent key={col.id} column={col} />
))}
</div>
<DragOverlay>
{activeCard ? <CardItem card={activeCard} isDragging /> : null}
</DragOverlay>
</DndContext>
);
}The distance: 8 activation constraint on PointerSensor prevents accidental drags when the user just clicks a card. Without it, single clicks on text inside cards trigger the drag — a notorious pain point. Eight pixels feels intentional.
Notice that onDragOver mutates state immediately to give instant column-switch feedback, while onDragEnd handles only same-column reordering. This split is the standard @dnd-kit pattern and it's what makes the UX feel native.
Column and Card Components with Tailwind Styling
The column component wraps cards in a SortableContext so @dnd-kit knows the order of items within each droppable area. The strategy you pass tells the library how to animate placeholder positions — verticalListSortingStrategy is correct for a vertical card list.
// KanbanColumn.tsx
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useDroppable } from '@dnd-kit/core';
import { KanbanColumn } from '../types/kanban';
import { CardItem } from './CardItem';
const PRIORITY_COLORS = {
high: 'bg-red-500/20 border-red-500/40 text-red-400',
medium: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-400',
low: 'bg-emerald-500/20 border-emerald-500/40 text-emerald-400',
} as const;
export function KanbanColumnComponent({ column }: { column: KanbanColumn }) {
const { setNodeRef, isOver } = useDroppable({ id: column.id });
return (
<div
ref={setNodeRef}
className={`flex flex-col w-72 shrink-0 rounded-xl p-4 gap-3
bg-white/5 border border-white/10 transition-colors duration-150
${isOver ? 'border-indigo-500/60 bg-indigo-500/10' : ''}`}
>
<h2 className="text-sm font-semibold text-white/70 uppercase tracking-widest px-1">
{column.title}
<span className="ml-2 text-white/40 font-normal">{column.cards.length}</span>
</h2>
<SortableContext items={column.cards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{column.cards.map((card) => (
<CardItem key={card.id} card={card} />
))}
</SortableContext>
</div>
);
}
// CardItem.tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { KanbanCard } from '../types/kanban';
const PRIORITY_BADGE: Record<string, string> = {
high: 'bg-red-500/20 text-red-400 border border-red-500/30',
medium: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
low: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
};
export function CardItem({ card, isDragging = false }: { card: KanbanCard; isDragging?: boolean }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging: isSortableDragging } = useSortable({ id: card.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isSortableDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`rounded-lg p-3 bg-white/8 border border-white/10 cursor-grab
active:cursor-grabbing select-none hover:border-white/25
transition-all duration-100 ${
isDragging ? 'shadow-xl shadow-black/40 rotate-1 scale-105 border-indigo-400/40' : ''
}`}
>
<p className="text-sm text-white/90 font-medium leading-snug mb-2">{card.title}</p>
{card.description && (
<p className="text-xs text-white/40 leading-relaxed mb-2">{card.description}</p>
)}
<span className={`inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-full ${
PRIORITY_BADGE[card.priority]
}`}>
{card.priority}
</span>
</div>
);
}The isOver state on the droppable column gives a nice indigo highlight when you hover a card over it — a 150ms CSS transition keeps it from feeling jarring. This kind of micro-feedback is what separates a polished DnD UI from a janky one.
The rotate-1 scale-105 on the DragOverlay card is subtle but effective. It signals to the user that the card is in a 'lifted' state. Same effect used in animated button interactions — a small transform communicates state without an explicit label.
Adding a New Card with an Inline Form
A Kanban board without a way to add cards is just a static wireframe. The cleanest pattern is a collapsible inline form at the bottom of each column — one click to expand, Enter to save, Escape to cancel. No modal, no separate route.
// AddCardForm.tsx
import { useState, useRef, useEffect } from 'react';
import { KanbanCard, ColumnId } from '../types/kanban';
interface Props {
columnId: ColumnId;
onAdd: (card: Omit<KanbanCard, 'id'>) => void;
}
export function AddCardForm({ columnId, onAdd }: Props) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
function submit() {
if (!title.trim()) return;
onAdd({ title: title.trim(), columnId, priority: 'medium' });
setTitle('');
setOpen(false);
}
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="w-full text-left text-sm text-white/30 hover:text-white/60
px-1 py-2 rounded-lg hover:bg-white/5 transition-colors duration-100"
>
+ Add card
</button>
);
}
return (
<div className="flex flex-col gap-2">
<textarea
ref={inputRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
if (e.key === 'Escape') { setOpen(false); setTitle(''); }
}}
placeholder="Card title…"
rows={2}
className="w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2
text-sm text-white placeholder-white/30 resize-none
focus:outline-none focus:border-indigo-400/60 focus:ring-1 focus:ring-indigo-400/30"
/>
<div className="flex gap-2">
<button onClick={submit}
className="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500
text-white text-xs font-semibold transition-colors">
Add
</button>
<button onClick={() => { setOpen(false); setTitle(''); }}
className="px-3 py-1.5 rounded-lg text-white/40 hover:text-white/70
text-xs transition-colors">
Cancel
</button>
</div>
</div>
);
}The Enter shortcut with !e.shiftKey guard is important. Users expect Shift+Enter to insert a newline in a textarea — intercepting plain Enter for submit matches the convention of every chat or task app they've used. Don't override Shift+Enter.
Wire onAdd up in KanbanBoard with a nanoid() generated id (or crypto.randomUUID() if you're targeting modern browsers only) and append the new card to the relevant column's cards array. Keep the state update immutable — spread the column, spread the cards array.
Persisting Board State to localStorage
What's the point of a Kanban board that forgets everything on refresh? Persisting to localStorage takes about 15 lines. The trick is initialising state from storage and writing back on every change without triggering extra renders.
// useBoardState.ts
import { useState, useEffect } from 'react';
import { BoardState } from '../types/kanban';
import { INITIAL_STATE } from './initialState';
const STORAGE_KEY = 'empire-kanban-v1';
export function useBoardState() {
const [board, setBoard] = useState<BoardState>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as BoardState) : INITIAL_STATE;
} catch {
return INITIAL_STATE;
}
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(board));
}, [board]);
return [board, setBoard] as const;
}The lazy initialiser in useState(() => ...) runs only once, so you're not reading localStorage on every render. The useEffect write runs after every board change — that's fine because board updates are always user-initiated (drag end, add card), never in a hot loop.
For production you'd replace localStorage with an API call in the effect. The shape stays identical; you'd just swap localStorage.setItem for fetch('/api/board', { method: 'PUT', body: JSON.stringify(board) }). If you need real-time sync across tabs, look at pairing this with a theme toggle style of cross-tab storage event listener to broadcast board changes.
Version the storage key (e.g., empire-kanban-v1) so future schema changes don't try to parse stale data. Increment the suffix when the BoardState shape changes.
Performance, Accessibility, and What to Build Next
Performance is rarely a problem for Kanban boards until you have hundreds of cards per column. At that scale, wrap individual CardItem components in React.memo — the useSortable hook returns a stable transform object reference during non-drag phases, so memo will short-circuit correctly.
Accessibility is where @dnd-kit genuinely earns its place over older libraries. The KeyboardSensor lets users Tab to a card, press Space to pick it up, use arrow keys to move it, and press Space again to drop it. Screen readers announce the drag state via aria-roledescription attributes that the library injects automatically. You get WCAG 2.1 Level AA drag interactions without writing a single aria attribute yourself.
What to build next? Column creation and deletion, card detail modals with a rich description editor, label filtering, and swimlane grouping by assignee are the typical next steps. For a smoother animated layout when columns change, pair this component with the animated tabs pattern to handle column-header tab switching. And if your product page needs a scrollable showcase of Kanban cards side by side, the React carousel component wraps that nicely.
FAQ
react-beautiful-dnd is effectively unmaintained as of 2023 and has open React 18 concurrent mode bugs. @dnd-kit is actively maintained, tree-shakeable, works correctly with React 18 and React 19, and ships with built-in keyboard accessibility. It's the straightforward choice for new projects.
Use the activationConstraint: { distance: 8 } option on PointerSensor (shown in the code above). This requires the pointer to travel 8px before a drag activates. For buttons specifically, you can also use the useSortable hook's attributes spread — it includes data-dnd-handle which you can scope to a dedicated drag-handle element instead of the whole card.
The board itself must be a Client Component because it uses useState, useEffect, and event listeners. Mark KanbanBoard.tsx with 'use client'. You can still fetch initial board data in a Server Component parent and pass it as props — that's the recommended pattern. The DnD library and all its hooks are client-only.
Define a ColumnId union type (e.g., type ColumnId = 'todo' | 'in-progress' | 'done') and store it on each card object. Then cast over.id to ColumnId in your drag handlers and TypeScript will catch any string that doesn't match the union. See the types section above for the full pattern.
Use a lazy useState initialiser — useState(() => { try { return JSON.parse(localStorage.getItem(key) ?? '') } catch { return defaults } }) — to read localStorage only on the client. Never read it at module scope. This is safe in Next.js App Router with 'use client'. For SSR-rendered boards, hydrate from server-fetched data instead.
Pass transition: 'transform 150ms ease' in the inline style of CardItem (alongside the CSS.Transform.toString output). @dnd-kit sets transform to null after drop, which triggers the CSS transition back to the resting position. Keep the duration at 100–200ms — anything longer feels sluggish for drag interactions.