A Kanban board is a classic project management tool. Here is how to build one with drag-and-drop.
Step 1: Install Dependencies
pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Step 2: Define Types
// types/kanban.ts
export interface Card {
id: string;
title: string;
description?: string;
priority: "low" | "medium" | "high";
assignee?: string;
}
export interface Column {
id: string;
title: string;
cards: Card[];
}
Step 3: Board State Management
"use client";
import { useState, useCallback } from "react";
import type { Column, Card } from "@/types/kanban";
const initialColumns: Record<string, Column> = {
todo: { id: "todo", title: "To Do", cards: [] },
"in-progress": { id: "in-progress", title: "In Progress", cards: [] },
review: { id: "review", title: "Review", cards: [] },
done: { id: "done", title: "Done", cards: [] },
};
export function useKanbanBoard() {
const [columns, setColumns] = useState(initialColumns);
const addCard = useCallback((columnId: string, card: Card) => {
setColumns((prev) => ({
...prev,
[columnId]: {
...prev[columnId],
cards: [...prev[columnId].cards, card],
},
}));
}, []);
const moveCard = useCallback(
(fromColumn: string, toColumn: string, fromIndex: number, toIndex: number) => {
setColumns((prev) => {
const updated = { ...prev };
const fromCards = [...updated[fromColumn].cards];
const [moved] = fromCards.splice(fromIndex, 1);
if (fromColumn === toColumn) {
fromCards.splice(toIndex, 0, moved);
updated[fromColumn] = { ...updated[fromColumn], cards: fromCards };
} else {
const toCards = [...updated[toColumn].cards];
toCards.splice(toIndex, 0, moved);
updated[fromColumn] = { ...updated[fromColumn], cards: fromCards };
updated[toColumn] = { ...updated[toColumn], cards: toCards };
}
return updated;
});
},
[]
);
const deleteCard = useCallback((columnId: string, cardId: string) => {
setColumns((prev) => ({
...prev,
[columnId]: {
...prev[columnId],
cards: prev[columnId].cards.filter((c) => c.id !== cardId),
},
}));
}, []);
return { columns, addCard, moveCard, deleteCard };
}
Step 4: Kanban Board Component
"use client";
import {
DndContext,
DragOverlay,
closestCorners,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useState } from "react";
import { useKanbanBoard } from "./useKanbanBoard";
import { KanbanColumn } from "./KanbanColumn";
import { KanbanCard } from "./KanbanCard";
import type { Card } from "@/types/kanban";
export function KanbanBoard() {
const { columns, addCard, moveCard } = useKanbanBoard();
const [activeCard, setActiveCard] = useState<Card | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
function findColumn(cardId: string) {
return Object.values(columns).find((col) =>
col.cards.some((c) => c.id === cardId)
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={(event) => {
const col = findColumn(event.active.id as string);
const card = col?.cards.find((c) => c.id === event.active.id);
if (card) setActiveCard(card);
}}
onDragEnd={(event) => {
setActiveCard(null);
const { active, over } = event;
if (!over) return;
const fromCol = findColumn(active.id as string);
const toCol =
findColumn(over.id as string) ||
columns[over.id as string];
if (!fromCol || !toCol) return;
const fromIndex = fromCol.cards.findIndex((c) => c.id === active.id);
const toIndex = toCol.cards.findIndex((c) => c.id === over.id);
moveCard(fromCol.id, toCol.id, fromIndex, toIndex === -1 ? 0 : toIndex);
}}
>
<div className="flex gap-4 overflow-x-auto p-4">
{Object.values(columns).map((column) => (
<SortableContext
key={column.id}
items={column.cards.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
<KanbanColumn column={column} onAddCard={addCard} />
</SortableContext>
))}
</div>
<DragOverlay>
{activeCard ? <KanbanCard card={activeCard} isOverlay /> : null}
</DragOverlay>
</DndContext>
);
}
Step 5: Column Component
"use client";
import { useDroppable } from "@dnd-kit/core";
import { Plus } from "lucide-react";
import { useState } from "react";
import { KanbanCard } from "./KanbanCard";
import type { Column, Card } from "@/types/kanban";
export function KanbanColumn({
column,
onAddCard,
}: {
column: Column;
onAddCard: (columnId: string, card: Card) => void;
}) {
const [isAdding, setIsAdding] = useState(false);
const [newTitle, setNewTitle] = useState("");
const { setNodeRef } = useDroppable({ id: column.id });
function handleAdd() {
if (!newTitle.trim()) return;
onAddCard(column.id, {
id: crypto.randomUUID(),
title: newTitle.trim(),
priority: "medium",
});
setNewTitle("");
setIsAdding(false);
}
return (
<div
ref={setNodeRef}
className="w-72 shrink-0 rounded-xl bg-gray-100 p-3 dark:bg-gray-800"
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">{column.title}</h3>
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">
{column.cards.length}
</span>
</div>
<div className="space-y-2">
{column.cards.map((card) => (
<KanbanCard key={card.id} card={card} />
))}
</div>
{isAdding ? (
<div className="mt-2">
<input
autoFocus
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="Card title..."
className="w-full rounded-lg border bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-900"
/>
<div className="mt-1 flex gap-1">
<button
onClick={handleAdd}
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
>
Add
</button>
<button
onClick={() => setIsAdding(false)}
className="rounded px-3 py-1 text-xs hover:bg-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setIsAdding(true)}
className="mt-2 flex w-full items-center gap-1 rounded-lg p-2 text-sm text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<Plus className="h-4 w-4" /> Add card
</button>
)}
</div>
);
}
Step 6: Card Component
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import type { Card } from "@/types/kanban";
const priorityColors = {
low: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
high: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
};
export function KanbanCard({
card,
isOverlay,
}: {
card: Card;
isOverlay?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`rounded-lg border bg-white p-3 shadow-sm dark:border-gray-600 dark:bg-gray-900 ${
isOverlay ? "shadow-xl ring-2 ring-blue-500" : ""
}`}
>
<div className="flex items-start gap-2">
<button
{...attributes}
{...listeners}
className="mt-0.5 cursor-grab text-gray-400 active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1">
<p className="text-sm font-medium">{card.title}</p>
{card.description && (
<p className="mt-1 text-xs text-gray-500">{card.description}</p>
)}
<span
className={`mt-2 inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${
priorityColors[card.priority]
}`}
>
{card.priority}
</span>
</div>
</div>
</div>
);
}
Want a Custom Project Management Tool?
We build web applications with drag-and-drop interfaces, real-time collaboration, and intuitive UX. Contact us to discuss your project.