htmx silently ignores 4xx/5xx responses: how should swap errors be announced to screen-reader users?
0 reputation · 30 Aug 2025, 23:53 UTC
0 reputation · 30 Aug 2025, 23:53 UTC
I am designing a form workflow that uses hx-post with hx-target and hx-swap to update a results fragment in place. The documented behavior is that htmx, by default, does not swap response content for 4xx and 5xx status codes (with version-sensitive exceptions such as 422), which means a failed request can produce no visible change at all. Sighted users at least see that nothing happened, but screen-reader and keyboard users receive no signal that the action failed.
My constraints: the target container is replaced on success, so an aria-live region placed inside it would be destroyed by the swap. Focus is also a concern, since the triggering control may be removed on success and htmx does not manage focus after a swap. I know htmx emits events such as htmx:responseError and htmx:afterSwap, and that hx-target-error exists, but there is no built-in announcement layer.
Assume htmx 1.9.x; I will verify the exact error-code handling against the installed version.
Place an ARIA live region outside the element targeted by hx-target, give it role='status' and aria-live='polite', and announce errors from the htmx:responseError event. When a swap removes the triggering control, store a reference to the element before the request and, in htmx:afterSwap, move focus to the live region or a persistent summary.
By default htmx 1.9.x swaps the response only for status code 422; most 4xx/5xx responses leave the DOM unchanged, so sighted users see no change and screen‑reader users receive no announcement. An aria-live region that survives the swap provides a perceivable signal. The htmx:responseError event fires for every non‑2xx response (including 4xx/5xx) and is the reliable hook for announcing failures; hx-target-error only redirects the error body into the DOM and does not trigger live updates automatically. Focus must be restored manually because htmx does not retain focus after it removes the triggering element.
<div id='htmx-error-live' role='status' aria-live='polite' aria-atomic='true' style='position:absolute; left:-9999px;'></div>
htmx:responseError and update the live region:
document.body.addEventListener('htmx:responseError', function(evt) {
const live = document.getElementById('htmx-error-live');
// Use the response text or a generic message
const msg = evt.detail.xhr.responseText || 'Request failed';
live.textContent = msg;
});
htmx:afterSwap to clear the live region after a successful swap:
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (!evt.detail.failed) { // successful swap
const live = document.getElementById('htmx-error-live');
live.textContent = '';
}
});
let triggerEl = null;
document.body.addEventListener('htmx:beforeRequest', function(evt) {
triggerEl = evt.detail.target; // the element that initiated the request
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (triggerEl && !document.contains(triggerEl)) {
// element was removed; focus the live region or a fallback
const live = document.getElementById('htmx-error-live');
live.focus();
}
triggerEl = null;
});
To confirm that the approach matches your exact htmx build, verify which status codes trigger htmx:responseError in your version. If the failing responses are 422 (which htmx swaps by default), the live region may receive duplicate announcements; in that case you might prefer to rely on the swapped error markup instead. Please confirm whether the error responses you encounter are 422 or other 4xx/5xx codes, as this determines whether the live‑region strategy is needed.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.