Add Drag and Drop
The kanban board works, but moving tasks requires clicking a menu. Let’s add drag-and-drop so users can grab a task and drag it to a different column. We will use @dnd-kit, a popular drag-and-drop library for React.
Install @dnd-kit
pnpm add @dnd-kit/core
We will start with the core package:
@dnd-kit/core: the core library withDndContext,useDraggable,useDroppable, andDragOverlay
The Key Concepts
@dnd-kit has three pieces:
DndContextwraps the entire drag-and-drop area. It tracks which item is being dragged and fires events likeonDragStartandonDragEnd.useDroppablemakes an element a drop target. Each column will be a drop target identified by its status ("todo","in-progress","done").useDraggablemakes an element draggable. Each task card will be draggable, identified by its_id.
The order is this. The user grabs a draggable item, drags it over a droppable target, and releases it. At that point onDragEnd fires with the dragged item’s ID and the drop target’s ID.
We will also use DragOverlay so the user sees a floating copy of the task while dragging. This usually feels better than moving the original DOM node directly.
Update KanbanBoard
Replace the contents of src/components/kanban-board.tsx:
import { useState } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import Column from "@/components/column";
import TaskCard from "@/components/task-card";
import AddTaskDialog from "@/components/add-task-dialog";
type TaskStatus = "todo" | "in-progress" | "done";
const COLUMNS: { title: string; status: TaskStatus }[] = [
{ title: "To Do", status: "todo" },
{ title: "In Progress", status: "in-progress" },
{ title: "Done", status: "done" },
];
function KanbanBoard() {
const tasks = useQuery(api.tasks.list);
const updateStatus = useMutation(api.tasks.updateStatus);
const [activeTask, setActiveTask] = useState<Doc<"tasks"> | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
function handleDragStart(event: DragStartEvent) {
const task = tasks?.find((t) => t._id === event.active.id);
setActiveTask(task ?? null);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) {
setActiveTask(null);
return;
}
const taskId = active.id as Id<"tasks">;
const newStatus = over.id as TaskStatus;
const task = tasks?.find((t) => t._id === taskId);
if (task && task.status !== newStatus) {
updateStatus({ id: taskId, status: newStatus });
}
setActiveTask(null);
}
if (tasks === undefined) {
return <p className="text-muted-foreground">Loading tasks...</p>;
}
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div>
<div className="mb-6 flex justify-end">
<AddTaskDialog />
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{COLUMNS.map((column) => (
<Column
key={column.status}
title={column.title}
status={column.status}
tasks={tasks.filter((task) => task.status === column.status)}
/>
))}
</div>
</div>
<DragOverlay>
{activeTask ? <TaskCard task={activeTask} isOverlay /> : null}
</DragOverlay>
</DndContext>
);
}
export default KanbanBoard;
Here is what is new:
-
DndContextwraps the board. It receivessensors(how drags are detected) and two event handlers. -
useSensorsandPointerSensordetect mouse/touch drags. TheactivationConstraint: { distance: 5 }means the user must move 5 pixels before a drag starts. This prevents accidental drags when clicking the ellipsis menu. -
activeTaskstate tracks which task is currently being dragged. This drives theDragOverlay. -
handleDragStartfinds the task being dragged and stores it in state. -
handleDragEndresponds when the user drops the task. We check if it was dropped on a column (over) and if the column is different from the task’s current status. If so, we call theupdateStatusmutation. At the end, we clearactiveTaskso the overlay disappears. -
DragOverlayrenders a floating copy of the task card while dragging. TheisOverlayprop lets the card style itself differently (we will add this next).
Checkpoint: Commit your progress.
git add .
git commit -m "kanban-11: Add drag and drop with @dnd-kit"
git push