Drag and Drop with HTMX and SortableJS
I was inspired by this YouTube video to learn some new things about HTMX. A sortable drag and drop is always a nice feature to have on your website, so why not get some insight on how it can be implemented in a Django application using modern tooling with uv and nanodjango.
Note: This article has been updated to reflect the latest developments in the Django community - using nanodjango for one-off projects and uv for Python package management.
You can explore the file structure in StackBlitz, but note that Python/uv execution isn’t supported there - you’ll need to run it locally to see the functionality.
The key takeaways from the video (for me) were:
- We use SortableJS to implement the required JavaScript functionality.
- We follow the instructions in the htmx Examples on how to integrate with HTMX
- We write some simple backend code for the view, to keep track of the changes made in the frontend.
I decided to avoid the implementation with a Database, since it can be easily mocked with a list, stored as the variable movies
in the global scope.
For this I generated a list with ChatGPT
movies = [
"The Shawshank Redemption",
"Inception",
"The Godfather",
"Pulp Fiction",
"Forrest Gump",
"The Matrix",
"Parasite",
"Back to the Future",
"The Dark Knight",
"Avatar",
]
And implemented the “sort” view as follows:
@app.route("/sort/")
def sort(request):
global movies
if request.method != 'POST':
return "Method not allowed", 405
new_order = request.POST.getlist("movie")
new_list = [movies[int(i)] for i in new_order]
movies = new_list
return render(request, "_movies.html", {"movies": movies})
The rest was just copy-paste from the htmx Examples.
And I applied some styling with Bulma, to make it a bit prettier.
Single File Django Application with uv + nanodjango
You can create these files in a folder:
sortable_movies.py
templates/index.html
templates/_movies.html
and execute:
uv run --python 3.12 sortable_movies.py
in a shell, to get the example running locally.
sortable_movies.py
#!/usr/bin/env -S uv run --python 3.12
# /// script
# dependencies = ["nanodjango"]
# ///
from nanodjango import Django
from django.shortcuts import render
app = Django()
movies = [
"The Shawshank Redemption",
"Inception",
"The Godfather",
"Pulp Fiction",
"Forrest Gump",
"The Matrix",
"Parasite",
"Back to the Future",
"The Dark Knight",
"Avatar",
]
@app.route("/")
def index(request):
return render(request, "index.html", {"movies": movies})
@app.route("/sort/")
def sort(request):
global movies
if request.method != 'POST':
return "Method not allowed", 405
new_order = request.POST.getlist("movie")
new_list = [movies[int(i)] for i in new_order]
movies = new_list
return render(request, "_movies.html", {"movies": movies})
if __name__ == "__main__":
app.run()
templates/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Django + HTMX + Sortable.js</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.0/css/bulma.min.css">
<!-- jsDelivr :: Sortable :: Latest (https://www.jsdelivr.com/package/npm/sortablejs) -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js"
integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm"
crossorigin="anonymous"></script>
</head>
<body>
<div class="container">
<form class="sortable" hx-post="/sort/" hx-trigger="end">
{% include "_movies.html" %}
</form>
</div>
<script>
htmx.onLoad(function (content) {
var sortables = content.querySelectorAll(".sortable");
for (var i = 0; i < sortables.length; i++) {
var sortable = sortables[i];
var sortableInstance = new Sortable(sortable, {
animation: 150,
ghostClass: 'blue-background-class',
// Make the `.htmx-indicator` unsortable
filter: ".htmx-indicator",
onMove: function (evt) {
return evt.related.className.indexOf('htmx-indicator') === -1;
},
// Disable sorting on the `end` event
onEnd: function (evt) {
this.option("disabled", true);
}
});
// Re-enable sorting on the `htmx:afterSwap` event
sortable.addEventListener("htmx:afterSwap", function () {
sortableInstance.option("disabled", false);
});
}
})
</script>
</body>
</html>
templates/_movies.html
{% csrf_token %}
<div class="htmx-indicator">Updating...</div>
{% for movie in movies %}
<div class="box has-background-primary-{{ forloop.counter0 }}0 has-text-primary-{{ forloop.counter0 }}0-invert" style="cursor: pointer">
<input type='hidden' name='movie' value='{{ forloop.counter0 }}' />
{{ movie }}
</div>
{% endfor %}