Skip to content

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.

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

What happens:

  1. data-hc-sortable marks the container; its element children are the sortable items.
  2. Every data-hc-sortable-handle is prepared at install (and for htmx-swapped content): touch-action: none, aria-pressed="false", and — when the handle is a bare glyph like — a default aria-label (i18n key sortable.handle).
  3. 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.
  4. A committed reorder is announced through the shared role="status" live region and dispatches a bubbling hc:sortchange event — only when the order actually changed.

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).

The handle is a real <button> — it is the keyboard interface:

KeyWhileDoes
Space / EnterGrab the item (data-grabbed="true", aria-pressed="true", announced)
/ grabbedMove the item up / back (announced)
/ grabbedMove the item down / forward (announced)
Space / EntergrabbedDrop — commit and fire hc:sortchange
Escapegrabbed or draggingCancel 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;
}
AttributeOnMeaning
data-dragging="true"the itema pointer drag is in flight
data-grabbed="true"the itemkeyboard grab is active
aria-pressedthe handlereflects the grab for assistive tech

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.

  • 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: none applies to handles only — the page still scrolls from anywhere else in the list.