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. Theidis the task’s Convex document ID. When the same component is rendered insideDragOverlay, 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. -
listenersandattributes— 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 letDragOverlayhandle the moving preview. The original task card stays in the list instead of physically moving across the page. -
isDragging—truewhile this card is being dragged. We use it to make the original card invisible withopacity-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 insideDragOverlay, we skip theref, 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