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 with DndContext, useDraggable, useDroppable, and DragOverlay

The Key Concepts

@dnd-kit has three pieces:

  • DndContext wraps the entire drag-and-drop area. It tracks which item is being dragged and fires events like onDragStart and onDragEnd.
  • useDroppable makes an element a drop target. Each column will be a drop target identified by its status ("todo", "in-progress", "done").
  • useDraggable makes 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:

  • DndContext wraps the board. It receives sensors (how drags are detected) and two event handlers.

  • useSensors and PointerSensor detect mouse/touch drags. The activationConstraint: { distance: 5 } means the user must move 5 pixels before a drag starts. This prevents accidental drags when clicking the ellipsis menu.

  • activeTask state tracks which task is currently being dragged. This drives the DragOverlay.

  • handleDragStart finds the task being dragged and stores it in state.

  • handleDragEnd responds 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 the updateStatus mutation. At the end, we clear activeTask so the overlay disappears.

  • DragOverlay renders a floating copy of the task card while dragging. The isOverlay prop 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