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.pytemplates/index.htmltemplates/_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 %}  