Sortable list
A list whose order the server owns — kanban columns, priority lists,
pinned dashboards — needs client-side reordering that stays honest
about who decides: the installSortable behavior moves DOM nodes and
reports the committed order; htmx owns the network. Because each
item carries its own hidden input, moving the item moves the input,
and the new order serializes into one request with zero bookkeeping —
all from markup, no inline JS.
Also known as: drag-and-drop reorder.
Live demo
Section titled “Live demo”The preview below is already live — reordering is client-side, so drag a handle (or focus it and press Space, then the arrow keys) and watch the list reorder. Persisting the result is one htmx attribute set away (below).
- Ship the release notes
- Review open PRs
- Update the roadmap
<ul class="hc-stack" data-hc-sortable> <li class="hc-item" data-hc-sortable-id="notes"> <button type="button" class="hc-button" data-variant="ghost" data-size="sm" data-hc-sortable-handle>⠿</button> <span class="hc-item__title">Ship the release notes</span> <input type="hidden" name="order[]" value="notes"> </li> <!-- … more items … --></ul>What happens:
data-hc-sortablemarks the container; its element children are the sortable items.- Every
data-hc-sortable-handleis prepared at install (and for htmx-swapped content):touch-action: none,aria-pressed="false", and — when the handle is a bare glyph like⠿— a defaultaria-label(i18n keysortable.handle). - Pointer drags start on the handle after a 4px threshold, so plain clicks pass through. The item reorders live under the pointer; row and column layouts are detected from geometry.
- A committed reorder is announced through the shared
role="status"live region and dispatches a bubblinghc:sortchangeevent — only when the order actually changed.
Persisting the order
Section titled “Persisting the order”Point htmx at the event; the hidden inputs do the serialization:
<ul class="hc-stack" data-hc-sortable data-hx-post="/items/order" data-hx-trigger="hc:sortchange" data-hx-include="this" data-hx-swap="none"> …</ul>The request body lists ids in the new DOM order —
order[]=b&order[]=a&order[]=c — because moving an <li> moves its
hidden input. Respond 204 No Content (optionally with an
HX-Trigger toast to
confirm), or return the re-rendered container and swap it when the
order affects computed labels. A <form> around the list plus a
submit button works identically without htmx.
Apps that prefer the event can skip the hidden inputs and read the detail instead:
list.addEventListener('hc:sortchange', ({ detail }) => { // detail = { item, from, to, order: ['b', 'a', 'c'] }});order lists each item’s data-hc-sortable-id (falling back to id,
else null).
Keyboard
Section titled “Keyboard”The handle is a real <button> — it is the keyboard interface:
| Key | While | Does |
|---|---|---|
| Space / Enter | — | Grab the item (data-grabbed="true", aria-pressed="true", announced) |
| ↑ / ← | grabbed | Move the item up / back (announced) |
| ↓ / → | grabbed | Move the item down / forward (announced) |
| Space / Enter | grabbed | Drop — commit and fire hc:sortchange |
| Escape | grabbed or dragging | Cancel and restore the original position |
Blurring the handle commits the current position. Every grab, move,
drop, and cancel is announced via the visually-hidden role="status"
region — translate the sortable.* keys with
setMessages() (ja ships
in locales/ja).
Dragging feels like dragging out of the box: the item in flight
tracks the pointer and gets a shadow lift (plus grab/grabbing
cursors on the handle), displaced siblings slide into their new slots,
and the drop settles the item into place. The motion rides the
motion scale
(--hc-motion-duration-fast) and is skipped entirely under
prefers-reduced-motion. The DOM order stays the single source of
truth — the animation layer never changes it.
State lives in attributes, so the default lift is overridable with plain selectors — no classes to toggle:
/* e.g. dim the item in flight on top of the built-in lift */[data-dragging='true'],[data-grabbed='true'] { opacity: 0.6;}| Attribute | On | Meaning |
|---|---|---|
data-dragging="true" | the item | a pointer drag is in flight |
data-grabbed="true" | the item | keyboard grab is active |
aria-pressed | the handle | reflects the grab for assistive tech |
Progressive enhancement
Section titled “Progressive enhancement”Without the behavior the list renders in server order and the handles are inert buttons — content stays readable and complete; reordering is an enhancement, not a dependency. For a no-JS ordering fallback, add per-item “move up / move down” submit buttons the server handles — they compose with this recipe untouched.
Accessibility
Section titled “Accessibility”- The visual order is the DOM order at all times — screen-reader order never drifts from what sighted users see.
- Handles must be focusable elements; the machine contract
(
checks.json) warns when a handle is not a<button>. touch-action: noneapplies to handles only — the page still scrolls from anywhere else in the list.