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.
Also known as: click-to-edit, edit-in-place.
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" aria-label="Item name" 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-label="Item name" 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>.) - The edit fragment has no visible
<label>, so give the input an accessible name (aria-label="Item name"above) — without one a screen-reader user lands on an unnamed textbox. - 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.
Progressive enhancement
Section titled “Progressive enhancement”- The display state is server-rendered text — without JavaScript the value stays readable; nothing on the page depends on a client render.
- The Edit affordance is a
type="button"carrying onlydata-hx-*attributes, so without htmx it is inert: activating it does nothing, navigates nowhere, and breaks nothing. In-place editing is the enhancement. - If the field must be editable without JavaScript, give the
affordance a real URL — render a link to a full-page edit form and
answer its POST with the
mutating-form
branching (
303back to the page). The fragment endpoints above stay as the enhanced path.
- 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.