Transfer
Assign and unassign members, permissions, or categories with two panes and move buttons — Ant Design’s Transfer / PrimeVue’s PickList, done the hypermedia way: membership lives on the server, every move is a POST that re-renders the whole form. The ids travel by native form serialization, the verbs by submit buttons — zero custom JavaScript.
Live demo
Section titled “Live demo”Check members and move them with the arrow buttons — every move is a
real POST and the whole form comes back re-rendered (counts refreshed,
boxes cleared); moving nothing answers a 422 with an inline alert. The
endpoint is a namespaced demo implementation of the
server response contract under
api/recipes/transfer/.
The markup
Section titled “The markup”<form class="hc-transfer" id="members" method="post" action="/roles/42/members" data-hx-post="/roles/42/members" data-hx-target="this" data-hx-swap="outerHTML" aria-label="Role members"> <fieldset class="hc-transfer__pane"> <legend class="hc-transfer__title">Available <span class="hc-transfer__count">(2)</span></legend> <div class="hc-transfer__list"> <label class="hc-item"> <input class="hc-checkbox" type="checkbox" name="available" value="7"> <span class="hc-item__title">Ada Lovelace</span> </label> <!-- … --> </div> </fieldset>
<div class="hc-transfer__controls"> <button class="hc-button" type="submit" name="action" value="add" data-hx-disabled-elt="this" aria-label="Add selected"> <span class="hc-transfer__arrow" aria-hidden="true">→</span> </button> <button class="hc-button" type="submit" name="action" value="remove" data-hx-disabled-elt="this" aria-label="Remove selected"> <span class="hc-transfer__arrow" aria-hidden="true">←</span> </button> </div>
<fieldset class="hc-transfer__pane"> <legend class="hc-transfer__title">Assigned <span class="hc-transfer__count">(1)</span></legend> <div class="hc-transfer__list"> <label class="hc-item"> <input class="hc-checkbox" type="checkbox" name="assigned" value="4"> <span class="hc-item__title">Alan Turing</span> </label> </div> </fieldset></form>hc-transfer (the stylesheet) is only layout: two fieldset panes
around a controls column, scrollable lists, RTL-flipping arrows
(.hc-transfer__arrow), and a stack on narrow containers. The rows are
plain hc-item labels.
How the ids travel
Section titled “How the ids travel”The form is the swap unit (data-hx-target="this" +
data-hx-swap="outerHTML"). Checked ids serialize natively —
name="available" in one pane, name="assigned" in the other; the
triggering button contributes action=add|remove:
POST /roles/42/membersavailable=7&available=9&action=addThe server reads only the pane matching the verb (a user can check both sides), treats each id idempotently, and answers with the re-rendered form: both panes updated, counts refreshed, checkboxes cleared. No selection state survives on the client — the response is the truth.
Server response contract
Section titled “Server response contract”| Case | Status | Body |
|---|---|---|
| Move applied (htmx) | 200 | the re-rendered <form class="hc-transfer"> |
| Move applied (no JS) | 303 | Location back to the page |
| Nothing selected / invalid | 422 | the same form with an inline .hc-alert[role="alert"] first child |
The 422 path uses the kit’s
error-handling allowance
(htmx ≥ 2 doesn’t swap non-2xx by default — enable it once via
htmx:beforeSwap). The
contract
also carries the CSRF notes.
No-JS degradation
Section titled “No-JS degradation”Without JavaScript the form posts natively and the server answers 303 → GET → full page with the updated form. The pattern works end-to-end; htmx only removes the full-page reload.
Scope and composition
Section titled “Scope and composition”- Need search within a pane? Compose the live-search recipe per pane — the server filters the pane it re-renders.
- Very large sets? Page the available pane server-side like the datagrid pager; the form-swap shape is unchanged.
- Drag-and-drop between panes is deliberately out of scope — the buttons are the accessible path, and a drop would still need the same POST.
Accessibility
Section titled “Accessibility”- Each pane is a
<fieldset>with a<legend>— screen readers announce “Available, group” when entering the checkboxes. - The glyph-only move buttons carry
aria-labels; the glyphs arearia-hiddenand mirror underdir="rtl". - The whole-form swap resets focus context; the panes re-announce through their legends. Keep the counts inside the legends so they are part of the group names.
Related
Section titled “Related”- Datagrid bulk actions — the same ids-by-serialization idea against a grid.
- Mutating form — the 4xx re-render convention this recipe reuses.
- Item — the row primitive inside the panes.