Skip to main content
Dragging a card is the first thing in this series that HTMX can’t do well. A drag has state that lives in the browser for as long as your finger is down, and round-tripping to the server on every pointer move would feel awful. So this is where islands earn their place. You’ll add position columns to the models, mount a small JavaScript component over the board, and let it update the UI immediately while the save happens behind it. You’ll use: OrderingMixin, __ordering_scope__, islands, the built-in draggable config, this.optimistic(), and an API route returning JSON.

Prerequisites

Your app needs the SQLite database, the Column and Card models, the HTMX routes and the two partials. Nothing else to do.

Step 1: Add OrderingMixin

OrderingMixin adds a position column and the methods to shuffle it.
models/column.py
models/card.py
__ordering_scope__ is the line that matters. Without it, positions would be unique across the whole table, and cards in different columns would fight over them. With it, each column’s cards start again at zero.

Step 2: The migration

Step 3: Update the services

services/column_service.py
services/card_service.py
move() handles the awkward case. A card crossing between columns has to leave one ordering scope and join another, so it moves across first, parks at the end, and only then slots into position.

Step 4: The move API route

routes/api/board.py
This is the first API route in the series, and the distinction is worth stating plainly. HTMX routes return HTML because the browser is going to insert it. This route returns JSON because the island already moved the element and only needs to report where it landed.

Step 5: The island

static/islands/kanban-board.js

Step 6: Add the drag handle

templates/partials/card.html
Two changes: data-id so the island can identify the card, and the handle itself.

Step 7: Make the column a drop zone

templates/partials/column.html
The data-id on .column-cards is what makes it a drop zone. The empty-placeholder class exists so the “No cards yet” line disappears when a card arrives.

Step 8: Mount the island

templates/pages/board.html
Islands load from the Vite dev server in debug, for hot reload, and through feather_asset() in production, which resolves the content-hashed build output. That’s why islands get their own block rather than sitting in scripts.

Step 9: The drag CSS

static/css/app.css
The islands runtime adds three classes for you: .dragging on the element in flight, .drag-over on the zone under the pointer, and .feather-drop-placeholder on the marker showing where it will land. The scaffolded app.css has base styles for all three, and these are the Kanban-specific overrides. Add them inside the existing @layer components block.

Step 10: Test it

  1. Grab a card by its six-dot handle and drag it.
  2. Drop it above or below another card in the same column.
  3. Drag one into a different column.
  4. Watch it land immediately rather than after a round trip.
  5. Refresh. The positions held.

Prompt Claude

feather check has two rules aimed at exactly this part. missing-island catches a template mounting an island that doesn’t exist, and orphan-island catches an island nothing mounts. Both are easy to trip while wiring this up.

Checkpoint

  • Columns and cards have stable positions
  • A card can be dragged within a column and between columns
  • The dragged card dims, and a placeholder shows where it will land
  • The card lands instantly rather than after a round trip
  • Positions survive a refresh
  • feather check passes
Files you created or changed

What you learned

  • OrderingMixin and the methods it adds
  • __ordering_scope__ for positions relative to a parent
  • Declaring and mounting an island
  • The built-in draggable config
  • this.optimistic() and rollback on failure
  • API routes that return JSON rather than HTML
  • How islands load differently in debug and production

Next: add real users

Google sign-in, roles, the admin panel, file attachments and PDF export.