
Mega Tutorial: Build a Trello Clone - Part 6
04 April, 2025
In this episode from our "Build a Trello Clone" series, we will be handling the dragging and dropping tasks between columns. For me, that was the coolest feature of Trello when I first started using it!
Creating Alpine.js Board Application
Dragging and Dropping might seem complex, but Alpine.js makes it easy for us.
Let's first write the application that will be assigned to the container of the columns
(this can be included in the content
block inside the boards/templates/boards/board_detail.html
file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | <script> BoardApp = function() { return { draggedTask: null, startDrag(event, taskId, listId) { this.draggedTask = taskId; }, endDrag() { this.draggedTask = null; }, moveTask(event, targetListId) { if (this.draggedTask === null || this.draggedFromList === targetListId) { return; } taskId = this.draggedTask; // Reset dragged state this.draggedTask = null; const data = new URLSearchParams(); data.append("task_id", taskId); data.append("column_id", targetListId); data.append("csrfmiddlewaretoken", "{{ csrf_token }}"); data.append("action", "move_card"); // Send update to backend fetch('{% url 'board_detail' pk=object.pk %}', { method: 'POST', body: data, redirect: 'manual' }).then(response => { // Refresh page window.location.href = '{% url 'board_detail' pk=object.pk %}' }) } } } </script> |
Drag/Drop Events
We will be using the following Alpine.js events:
- @dragstart - Event triggered on the Task card when we start dragging
- @dragend - Event triggered on the Task card when we stop dragging
- @drop - Event triggered on the Column when a card is dropped
Initializing BoardApp
We now need to modify a bit the container <div>
from:
1 2 3 | {% block content %} <div class="overflow-x-scroll"> <div class="flex min-w-max gap-4"> |
to:
1 2 3 | {% block content %} <div class="overflow-x-scroll" x-data="BoardApp()"> <div class="flex min-w-max gap-4 items-start"> |
Attaching the event handlers for tasks
Let's go to boards/templates/boards/partial/task_card.html
and add these event handlers to the Task card:
1 2 3 4 5 6 7 8 | <div class="bg-gray-200 rounded p-4 border border-black flex flex-col justify-between" draggable="true" @dragstart="startDrag(event, {{ card.pk }}, {{ card.column.pk }})" @dragend="endDrag()"> <div> <a href="{% url 'card_detail' pk=card.pk board_id=card.column.board.pk %}"> {{ object.title }} ... |
Attach event handlers on columns
Let's head back to boards/templates/boards/board_detail.html
and attach these events to the columns so that we can drop tasks in them:
1 2 3 4 5 6 7 8 9 10 | {% for column in object.columns.all %} <div class="bg-white rounded shadow p-4 w-72 border-2 border-black"> <h2 class="font-bold text-lg mb-4">{{ column.title }}</h2> <div class="space-y-4"> <div class="space-y-4 min-h-4" @dragover.prevent @drop="moveTask(event, {{ column.pk }})"> {% for card in column.cards.all %} {% include "boards/partial/task_card.html" with object=card %} {% endfor %} |
Write the backend code
Notice how in the moveTask
function we posted the task_id
and the column_id
,
while specifying that the action is move_task
. There's no actual code that handles this action.
Let's write it!
We simply need to add the move_card
handler inside the post
method of the BoardDetailView
view:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | @method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', ) class BoardDetailView(DetailView): template_name = "boards/board_detail.html" model = Board def post(self, request, *args, **kwargs): self.object = self.get_object() if request.POST.get("action") == "create_column": title = request.POST.get("title") if title: Column.objects.create( board=self.object, title=title ) if request.POST.get("action") == "create_card": title = request.POST.get("title") column = get_object_or_404(Column, pk=int(request.POST.get("column_id"))) if title: Card.objects.create(title=title, column=column) if request.POST.get("action") == "move_card": # Handle Drag and Drop if request.POST.get("column_id", None) is not None: task = get_object_or_404(Card, pk=int(request.POST.get("task_id"))) column = get_object_or_404(Column, pk=int(request.POST.get("column_id"))) task.column = column task.save() return redirect("board_detail", pk=self.object.pk) |
Final Result
Here's how dragging and dropping tasks around looks like:

See you soon!