Kanban Board
A kanban board component made with dnd-kit, framer motion and tailwind css.
Preview
Not Started
3
Fix login page bug
none
4/1/2025
Fix login page bug
none
4/1/2025
none
4/4/2025
In Progress
2
Write project proposal
high
4/1/2025
none
4/4/2025
Completed
2
Add new feature to the app
none
4/1/2025
none
4/4/2025
Types
export enum TaskStatus {
NotStarted = "not_started",
InProgress = "in_progress",
Completed = "completed",
}
export enum TaskPriority {
None = "none",
Low = "low",
Medium = "medium",
High = "high",
Urgent = "urgent",
}
export type Task = {
id: number;
title: string;
status: TaskStatus;
priority: TaskPriority;
deadline: Date;
};
Code
import React from "react"; // Dnd import { DndContext, closestCorners, useSensor, useSensors, MouseSensor, TouchSensor, DragEndEvent, DragOverlay, } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { motion } from "framer-motion"; // Components import { Badge } from "@/components/ui/badge"; import { EllipsisVertical, EyeIcon, PencilIcon, TrashIcon } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; // Types import { Task, TaskPriority, TaskStatus, } from "@/components/main/KanbanBoard/types"; interface KanbanBoardProps { tasks: Task[]; setExpandedTaskId: (id: number | null) => void; setUpdateTaskId: (id: number | null) => void; deleteTask: (id: number) => void; formatStatus: (status: string) => React.ReactNode; onDragEnd: (taskId: number, newStatus: string) => void; } type TaskItemProps = { task: Task; setExpandedTaskId: (id: number | null) => void; setUpdateTaskId: (id: number | null) => void; deleteTask: (id: number) => void; isDragging?: boolean; index?: number; isGhost?: boolean; }; const TaskItem = ({ task, setExpandedTaskId, setUpdateTaskId, deleteTask, isDragging = false, index = 0, isGhost = false, }: TaskItemProps) => { const { attributes, listeners, setNodeRef } = useSortable({ id: task.id }); return ( <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: isGhost ? 0 : 1, y: isGhost ? 20 : 0 }} transition={{ duration: 0.4, delay: index * 0.1, ease: "easeOut", }} > <div ref={setNodeRef} {...attributes} {...listeners} className={`p-4 bg-white rounded-lg border border-gray-200/50 cursor-move ${ isDragging ? "opacity-50" : "" }`} > <div className="flex items-start justify-between gap-4"> <div className="flex-1"> <p className="font-medium">{task.title}</p> <div className="flex items-center gap-2 mt-2"> <Badge variant={ task.priority === TaskPriority.High ? "default" : "secondary" } > {task.priority} </Badge> <Badge variant="secondary"> {task.deadline.toLocaleDateString()} </Badge> </div> </div> <DropdownMenu> <DropdownMenuTrigger asChild> <EllipsisVertical className="w-4 h-4" /> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => setExpandedTaskId(task.id)}> <EyeIcon className="w-4 h-4 mr-2" /> View </DropdownMenuItem> <DropdownMenuItem onClick={() => setUpdateTaskId(task.id)}> <PencilIcon className="w-4 h-4 mr-2" /> Edit </DropdownMenuItem> <DropdownMenuItem onClick={() => deleteTask(task.id)}> <TrashIcon className="w-4 h-4 mr-2" /> Delete </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> </div> </motion.div> ); }; const KanbanBoard = ({ tasks, setExpandedTaskId, setUpdateTaskId, deleteTask, formatStatus, onDragEnd, }: KanbanBoardProps) => { const [activeId, setActiveId] = React.useState<number | null>(null); const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8, }, }) ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveId(null); if (!over) return; const taskId = Number(active.id); const container = over.data.current?.sortable?.containerId; const newStatus = String(container); onDragEnd(taskId, newStatus); }; const handleDragStart = (event: DragEndEvent) => { setActiveId(Number(event.active.id)); }; const activeTask = activeId ? tasks.find((task) => task.id === activeId) : null; const columns = { [TaskStatus.NotStarted]: [ ...tasks.filter((task) => task.status === TaskStatus.NotStarted), { id: tasks.length + 1, title: "", status: TaskStatus.NotStarted, priority: TaskPriority.None, deadline: new Date(), isGhost: true, }, ], [TaskStatus.InProgress]: [ ...tasks.filter((task) => task.status === TaskStatus.InProgress), { id: tasks.length + 2, title: "", status: TaskStatus.InProgress, priority: TaskPriority.None, deadline: new Date(), isGhost: true, }, ], [TaskStatus.Completed]: [ ...tasks.filter((task) => task.status === TaskStatus.Completed), { id: tasks.length + 3, title: "", status: TaskStatus.Completed, priority: TaskPriority.None, deadline: new Date(), isGhost: true, }, ], }; return ( <DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleDragEnd} onDragStart={handleDragStart} > <div className="grid grid-cols-3 gap-4 w-full m-10"> {Object.entries(columns).map(([status, columnTasks]) => ( <div key={status} id={status} data-status={status} className="flex flex-col gap-4 p-3 bg-gray-50 rounded-lg min-h-[500px]" > <div className="flex items-center justify-between"> <h3 className="font-medium text-sm">{formatStatus(status)}</h3> <Badge variant="default">{columnTasks.length}</Badge> </div> <SortableContext id={status} items={columnTasks.map((task) => task.id)} > <div className="flex flex-col gap-2 min-h-[100px]"> {columnTasks.map((task, index) => ( <TaskItem key={task.id} task={task} setExpandedTaskId={setExpandedTaskId} setUpdateTaskId={setUpdateTaskId} deleteTask={deleteTask} index={index} isGhost={task.isGhost} /> ))} </div> </SortableContext> </div> ))} </div> <DragOverlay> {activeTask && ( <TaskItem task={activeTask} setExpandedTaskId={setExpandedTaskId} setUpdateTaskId={setUpdateTaskId} deleteTask={deleteTask} isDragging={true} /> )} </DragOverlay> </DndContext> ); }; export default KanbanBoard;
Usage
<KanbanBoard
tasks={tasks}
setExpandedTaskId={setExpandedTaskId}
setUpdateTaskId={setUpdateTaskId}
deleteTask={deleteTask}
formatStatus={formatStatus}
onDragEnd={onDragEnd}
/>
Props
Name | Type | Required | Default |
tasks | Task[] | Yes | |
setExpandedTaskId | (id: number | null) => void | Yes | |
setUpdateTaskId | (id: number | null) => void | Yes | |
deleteTask | (id: number) => void | Yes | |
formatStatus | (status: string) => React.ReactNode | Yes | |
onDragEnd | (taskId: number, newStatus: string) => void | Yes |