Draggable Task Cards

Next, let’s make the task cards draggable. Then a user can drag a task from one column into another, and that changes the task’s status.

Make Task Cards Draggable

Update src/components/task-card.tsx:

import { useDraggable } from "@dnd-kit/core";
import { MoreHorizontal, Trash2 } from "lucide-react";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";

type TaskCardProps = {
  task: Doc<"tasks">;
  isOverlay?: boolean;
};

function TaskCard({ task, isOverlay }: TaskCardProps) {
  const updateStatus = useMutation(api.tasks.updateStatus);
  const removeTask = useMutation(api.tasks.remove);

  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
    id: task._id,
    disabled: isOverlay,
  });

  return (
    <Card
      ref={isOverlay ? undefined : setNodeRef}
      className={`transition-shadow hover:shadow-md ${isDragging ? "opacity-0" : ""} ${isOverlay ? "shadow-lg ring-2 ring-primary opacity-100" : ""}`}
    >
      <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
        <CardTitle
          className="flex-1 cursor-grab text-sm font-medium leading-snug active:cursor-grabbing"
          {...listeners}
          {...attributes}
        >
          {task.title}
        </CardTitle>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
              <MoreHorizontal className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            {task.status !== "todo" && (
              <DropdownMenuItem
                onClick={() =>
                  updateStatus({ id: task._id, status: "todo" })
                }
              >
                Move to To Do
              </DropdownMenuItem>
            )}
            {task.status !== "in-progress" && (
              <DropdownMenuItem
                onClick={() =>
                  updateStatus({ id: task._id, status: "in-progress" })
                }
              >
                Move to In Progress
              </DropdownMenuItem>
            )}
            {task.status !== "done" && (
              <DropdownMenuItem
                onClick={() =>
                  updateStatus({ id: task._id, status: "done" })
                }
              >
                Move to Done
              </DropdownMenuItem>
            )}
            <DropdownMenuSeparator />
            <DropdownMenuItem
              className="text-destructive"
              onClick={() => removeTask({ id: task._id })}
            >
              <Trash2 className="mr-2 h-4 w-4" />
              Delete
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </CardHeader>
      {task.description && (
        <CardContent>
          <p className="line-clamp-2 text-sm text-muted-foreground">
            {task.description}
          </p>
        </CardContent>
      )}
    </Card>
  );
}

export default TaskCard;

Here is what is new:

  • useDraggable({ id: task._id, disabled: isOverlay }) — registers this card as a draggable item. The id is the task’s Convex document ID. When the same component is rendered inside DragOverlay, we disable dragging so the overlay copy does not try to register as a second draggable item.

  • setNodeRef — tells @dnd-kit which DOM element to track for dragging.

  • listeners and attributes — spread onto the drag handle (the title). This means you grab the task by its title text. The ellipsis menu button is separate, so clicking it opens the menu instead of starting a drag.

  • No manual transform — we let DragOverlay handle the moving preview. The original task card stays in the list instead of physically moving across the page.

  • isDraggingtrue while this card is being dragged. We use it to make the original card invisible with opacity-0, which prevents the user from seeing both the source card and the overlay copy at the same time.

  • isOverlay — when this component is rendered inside DragOverlay, we skip the ref, disable dragging, and add a distinct visual style (shadow + ring).

Try It Out

Add a few tasks and try dragging them between columns. You should see:

  • The task card gets a grab cursor when you hover over the title
  • Dragging creates a floating copy with a ring highlight
  • The original card disappears while dragging, so you do not see a duplicate
  • Columns highlight when you drag over them
  • Dropping the task in a new column changes its status

The ellipsis menu still works for users who prefer clicking, and drag-and-drop is a quicker way to do the same thing with a mouse.

There is still one problem, and it is easy to miss. When you release the task over a valid column, the drop looks wrong for a moment. The task appears to go back to its original column before it shows up in the new one.

That does not mean the drop failed. The mutation is succeeding. The issue is that the board still renders from useQuery(api.tasks.list), so React keeps showing the old task list until Convex sends the updated query result back to the client.

We will fix that in the next section with an optimistic update.

Checkpoint: Commit your progress.

git add .
git commit -m "kanban-13: Make task cards draggable with useDraggable"
git push