Inline edit
inline-edit is the simplest “click to edit” pattern. The display
state and the edit state are two server-rendered HTML fragments at
the same URL, and htmx swaps between them with outerHTML.
Live demo
Section titled “Live demo”Activate Edit, change the name, and save — or save it blank to see
the 422 edit fragment with the error state, then Cancel back to the
original value. Every state is a server-rendered fragment from a demo
implementation of the contract, namespaced under
api/recipes/inline-edit/ (stateless: the current value rides along
as a query parameter); your app would use its own URL such as
/items/42/name.
Display state
Section titled “Display state”The default rendering of a row cell — the value plus a small, real
Edit <button>:
<span id="item-42-name"> Acme widgets <button class="hc-button" data-size="sm" type="button" data-hx-get="/items/42/name/edit" data-hx-target="closest span" data-hx-swap="outerHTML"> Edit </button></span>The button is the affordance: it is keyboard reachable (Tab, then
Enter or Space), announced by assistive tech, and needs no extra ARIA.
It targets closest span with outerHTML, so activating it replaces
the whole display node with the edit form.
Edit state
Section titled “Edit state”<!-- GET /items/42/name/edit --><form id="item-42-name" data-hx-put="/items/42/name" data-hx-target="this" data-hx-swap="outerHTML" style="display: inline-flex; gap: .25rem;"> <input name="name" class="hc-input" data-size="sm" value="Acme widgets" autofocus> <button class="hc-button" data-size="sm" data-variant="primary" type="submit">Save</button> <button class="hc-button" data-size="sm" type="button" data-hx-get="/items/42/name" data-hx-target="closest form" data-hx-swap="outerHTML">Cancel</button></form>Cancel targets closest form, so it swaps the whole edit form back to
the display fragment.
Save loop
Section titled “Save loop”The user types, presses Enter (or clicks Save). htmx submits the
form to PUT /items/42/name and the server returns the updated
display state:
<!-- PUT /items/42/name --><span id="item-42-name"> Acme widgets v2 <button class="hc-button" data-size="sm" type="button" data-hx-get="/items/42/name/edit" data-hx-target="closest span" data-hx-swap="outerHTML"> Edit </button></span>The outerHTML swap replaces the entire <form> with the display
<span> — the same node id, just a different element type. htmx
re-processes the new attributes automatically.
Validation
Section titled “Validation”For a server-side validation failure, return 422 with the edit
form re-rendered and the error message inline. Keep the same id so the
outerHTML swap targets the same node:
<!-- PUT /items/42/name → 422 --><form id="item-42-name" data-hx-put="/items/42/name" data-hx-target="this" data-hx-swap="outerHTML"> <div class="hc-field" data-invalid="true"> <input class="hc-input" data-size="sm" name="name" value="" aria-invalid="true" aria-describedby="item-42-name-error"> <p id="item-42-name-error" class="hc-field__message">Name is required.</p> </div> <button class="hc-button" data-size="sm" data-variant="primary" type="submit">Save</button> <button class="hc-button" data-size="sm" type="button" data-hx-get="/items/42/name" data-hx-target="closest form" data-hx-swap="outerHTML">Cancel</button></form>htmx ≥ 2 does not swap non-2xx responses by default. Allow the 422 swap once, globally — the same allowance the field-errors recipe documents:
document.body.addEventListener('htmx:beforeSwap', (event) => { if (event.detail.xhr.status === 422) { event.detail.shouldSwap = true; event.detail.isError = false; }});(Alternatives: answer 200 with the error fragment, or send
HX-Reswap / HX-Retarget headers. data-hx-target-422="this" also
works, but requires htmx’s response-targets extension.)
Accessibility
Section titled “Accessibility”- The Edit trigger is a real
<button>— keyboard reachable, announced by assistive tech, no extra ARIA needed. (See the note above before swapping it for a clickable<span>.) - Use
autofocuson the input so keyboard users land on the editable field immediately. - For Escape-to-cancel, add a small
keydownlistener in your own bundle that clicks the Cancel button (no inlineonkeydownhandlers — they break under a CSP) — or skip Escape support and rely on the visible Cancel button.
Server response contract
Section titled “Server response contract”| Request | Response |
|---|---|
GET /items/:id/name | Display fragment (<span id="item-:id-name"> with the Edit button). |
GET /items/:id/name/edit | Edit fragment (<form id="item-:id-name">…</form>). |
PUT /items/:id/name (200) | Display fragment with the new value. |
PUT /items/:id/name (422) | Edit fragment with data-invalid="true" and a .hc-field__message — needs the htmx:beforeSwap allowance above. |
All four responses share the same DOM id — that is what makes the swap reversible.
- Optimistic UI? Skip it for inline edits. The round trip is short, the server is authoritative, and the bookkeeping of rolling back a failed optimistic update outweighs the perceived speed-up.
- Keyboard parity. The Edit button keeps the display state Tab-reachable and Enter-activatable; if you swap it for a custom trigger, preserve that.
- Avoid mixing inline edit and remote-dialog for the same field. Pick one. Inline edit is right when the change is one value and the user is already looking at it. A remote dialog wins for multi-field updates.
Related
Section titled “Related”- Field component — for the validation styling pattern.
- Remote dialog recipe — for multi-field edits behind a modal.