htmx and Accessibility: What Gets Lost When HTML Is Swapped
I went through four builds of one demo with the keyboard: where the focus lands after an htmx swap and what you have to handle yourself.
by Jean Pierre Kolb ·
In Part 2 the robust htmx build of my demo did brilliantly. All content is in the HTML, every state has its own URL, and crawlers see everything. For accessibility that is not enough. A page can be complete for search engines and still lead people who use a keyboard or a screen reader into nowhere at exactly the places where htmx swaps HTML.
So I went through all four builds of the event calendar with the keyboard and recorded after each step where the focus was, which page title applied and which language the page declared. The result is a list of things htmx does not do for you.
A note on method: Measured on 29 September 2026 against the demo as served live, in a headless Chromium (
HeadlessChrome/155) via Playwright. I focused each target and then triggered it with real keyboard input (Enter,Escape). After each step I read out the active element, the title, thelangattribute, open dialogs and any live regions. I did not use a screen reader. What a screen reader actually announces I derive from the DOM state and the documentation, not from listening.
What happens during a swap
When you click a link, the browser loads a new document. It moves focus back to the top of the page, takes the title and language of the new page, and assistive technology notices the page change. None of that is the website's job.
When htmx swaps part of the page instead, the document stays the same. The browser has no reason to report anything. Whether focus ends up somewhere sensible, whether a screen reader learns about the change and whether title and language are right is then down to the library and to you. Four criteria of WCAG 2.2 (opens in a new tab) are particularly affected:
- 2.4.3 Focus Order (level A): focus must move in an order that preserves meaning and operability.
- 2.4.2 Page Titled (level A): pages need a title that describes their topic or purpose.
- 3.1.1 Language of Page (level A): the language of the page must be programmatically determinable.
- 4.1.3 Status Messages (level AA): status messages must be conveyed to assistive technology without moving focus.
The result at a glance
| Step | htmx (robust) | htmx (fragile) | Unpoly | no library |
|---|---|---|---|---|
| Trigger tab | focus → <body> | focus stays on the button | focus → tab link | new page, focus → <body> |
| “Load more” | focus → <body>, title becomes “Page 2” | loads silently while tabbing | focus → first event of the list | new page |
| Open quick info | dialog, focus → “Close”, title becomes the event | text appears, focus stays, no announcement | overlay, focus → overlay | dialog, focus → “Close” |
Escape | focus back to the trigger, title stays the event | – | focus back to the trigger | focus back to the trigger |
| Language link | lang stays the old value | lang changes | lang changes | lang changes |
There were no live regions (aria-live, role="status") and no aria-busy in any build or state. The following sections go through the rows one by one. I ran the measurement on the German pages of the demo; the language link there leads to English.
Tabs: focus falls to the top of the page
In the robust htmx build, a click on the “Workshop” tab replaces the whole catalogue, including the tab bar itself. The link that just had focus no longer exists afterwards, and focus falls to <body>. Keyboard users land back at the top and have to work their way back through the header and the tabs.
Why this happens is in the source code of htmx 2.0.11. After a swap, htmx only restores focus if the previously focused element had an id and an element with the same id exists in the new content. My tab links have no id. That suggests an obvious fix, which I derive from the code but did not measure in the demo: give every tab link a fixed id, for example id="tab-workshop". Then htmx finds the counterpart in the new catalogue and focuses it.
Unpoly does this on its own. For navigation it works through a list of focus strategies, and after the tab switch the focus was back on the “Workshop” link. In the fragile htmx build only the list below the buttons is swapped, not the buttons themselves, so focus stays there. In neither htmx build does a screen reader learn that the list has changed.
“Load more”: focus gone, title wrong
“Load more” in the robust htmx build appends the events from page 2, and focus falls to <body> again. On top of that I noticed a second problem I would have missed without the measurement: the page title then starts with “All Events, Page 2”, although the URL stays the same and the page now shows all twelve events.
The cause is a default. htmx reads the <title> from every response and applies it, even when hx-select inserts only a small part of the response. You can turn this off globally with htmx.config.ignoreTitle = true or specifically on the element:
<a href="…/page/2/" hx-get="…/page/2/" hx-select="#liste > li" hx-target="#liste"
hx-swap="beforeend ignoreTitle:true" hx-select-oob="#mehr">Load more</a>Unpoly leaves the title alone, but after appending it moves focus to the first event of the whole list, back to the top of the list and above the visible area. The next Tab goes to the first event again. That beats <body>, but it is not what I want from a growing list, where focus should land on the first new event, in Unpoly for example via the up-focus attribute. I did not try that in the demo.
The fragile build has a different problem. Its “load more” triggers via hx-trigger="revealed" as soon as the end of the list becomes visible. With the keyboard that happens as a side effect: when I reached the last events with Tab, the browser scrolled, the placeholder came into view and six new events appeared below the focus. Nothing announced it. There is no live region and no sign that the list has grown. (My first measurement pass had found the opposite here. The fact check showed that my probe had never actually set the focus. With real tabbing the result is clear.)
The quick info: <dialog> saves the day, the title does not
The quick info shows what a native element is worth. The robust htmx build opens a <dialog> via showModal(), the build without a library does it without any script via command="show-modal". Both open it as a modal dialog, and the browser does the rest. According to the HTML specification (opens in a new tab), focus moves into the dialog, in my demo to “Close”, the first focusable element. The rest of the page becomes inert, neither focusable nor visible to assistive technology. Escape closes the dialog, and focus returns to the element that opened it. That is exactly what I measured in both builds. According to their source code, Chromium, Gecko and WebKit all implement this focus return.
htmx contributes nothing here except loading the content. And it adds an error: the detail page the quick info comes from has its own <title>, and htmx takes it over. After opening, the page title starts with “Crime Night with a Local Author”, and still does after closing. The fix is the same as above: ignoreTitle:true in the quick-info link's hx-swap.
Unpoly's overlay does without <dialog> and still brings everything along: role="dialog", aria-modal, focus trapped inside the overlay, a named close button and focus returned to the trigger. I measured that too. One detail stood out: the close button's name is “Dismiss dialog” even on the German page. If you use Unpoly in another language, translate it.
The fragile htmx build shows what it looks like without all that. The quick-info text appears in a <div> below the list. Focus stays on the button, and there is no live region. A screen reader has no reason to read out the new text. That is a case for WCAG 4.1.3.
If you need such a pattern, the live region belongs in the markup from the start. MDN (opens in a new tab) explicitly recommends creating the region empty and filling it later, because a region inserted together with its content is often not announced:
<div id="kurzinfo" aria-live="polite"></div>htmx then only fills the content of the existing region, and the announcement can work. I deliberately left this out of the demo, because the fragile build is meant to show the typical first attempt.
Detail page and language: what hx-boost leaves behind
With hx-boost, htmx loads the detail page via Ajax and replaces the content of <body>. It takes over the title and puts focus on <body>, similar to a real page change. In this case Unpoly focuses <main>, the content rather than the header.
The clearest gap showed up with the language link. On the German htmx page I chose “English”. URL, title and content were English afterwards, but <html lang="de"> stayed. That is not a measurement error: with hx-boost, htmx only takes over the content of <body> and the <title>. It does not touch the attributes of the <html> element, not even with the head-support extension. Screen readers that pick their voice from the language declaration will then read English text with German pronunciation, and the page violates WCAG 3.1.1.
The fix is simple. A language switch is a real page change and should stay one:
<a href="/db/en/demo/event-calendar/htmx/" hreflang="en" lang="en" hx-boost="false">English</a>hx-boost="false" exempts the link from boosting, and the browser loads the English page in full with the correct lang. In the fragile build, which does not use hx-boost, and with Unpoly, the language was right after the switch.
Your levers in htmx
In summary, htmx 2 gives you these tools:
- Focus: fixed
ids on elements that should be focused again after a swap. For your own focus logic there are events such ashtmx:afterSettle. Thefocus-scrollmodifier inhx-swapdecides whether restoring focus scrolls (default: no). - Title:
ignoreTitle:trueinhx-swap, or globallyhtmx.config.ignoreTitle, wherever the response is cut out of a whole page. - Language:
hx-boost="false"for language switches. - Announcements: a live region that is in the markup from the start and that htmx only fills.
- Loading state: htmx sets the
htmx-requestclass and uses it to show elements withhtmx-indicator. That is invisible to assistive technology. htmx does not setaria-busy. The stringariadoes not appear once in the source code of 2.0.11.
The htmx documentation mentions accessibility in the context of progressive enhancement and gives general tips such as semantic HTML and visible focus. On live regions it says nothing. On focus there is a single sentence in the hx-swap reference: htmx preserves focus for inputs that have an id. That is not a reproach. htmx is deliberately small, and these decisions belong in your application. You just have to make them.
Unpoly and the build without a library
In this measurement Unpoly got almost everything right without me writing a line for it: focus on the tab, focus inside the list rather than on <body> (albeit at the top of the list), overlay with focus trap and return, focus on <main> after page changes, correct language. Unpoly does not set aria-busy either, though; the loading state is shown through CSS classes. That is the real value you get for the file size just under three and a half times that of htmx from Part 1.
The build without a library does not have the problems in the first place, because every step is a real page change and the dialog is native. It loads a whole page at every step, but it is the only one where I would not have to fix anything.
Legal context: the European Accessibility Act and the BFSG
In Germany, where this site is published, the Barrierefreiheitsstärkungsgesetz (opens in a new tab) (BFSG) has applied since 28 June 2025. It is the German implementation of the European Accessibility Act. Among other things it covers services in electronic commerce provided to consumers, essentially online shops and booking flows. Micro-enterprises providing services are exempt: fewer than ten employees and an annual turnover or balance sheet total of at most 2 million euros. For certain situations, transition periods run until mid-2030.
The law itself does not name WCAG. Through an ordinance it refers to requirements, and through harmonised standards to a presumption of conformity. In practice that is the European standard EN 301 549, whose version listed in the EU Official Journal builds on WCAG 2.1. ETSI published a new version with WCAG 2.2 in September 2026; it is not in the Official Journal yet.
This is not legal advice. But if your htmx application sells something to consumers, lost focus, a wrong page title and a wrong language declaration are no longer cosmetic issues.
What the series has shown
Three parts, one demo, one insight in three directions:
- Context (Part 1): htmx is small, free and quick to adopt. Its strength is that it prescribes little.
- Visibility (Part 2): as long as every state has a real URL, everything was in the first response in my measurement. What only arrives via JavaScript is not seen by most AI crawlers.
- Accessibility (this part): precisely because htmx prescribes little, focus, title, language and announcements stay with you. Unpoly takes a lot of that off your hands, the platform with real page changes and
<dialog>even more.
If I take one rule from the whole series, it is this: build the version without JavaScript first, and put htmx on top only where it really improves the interaction. Then visibility and the basic structure are right by themselves, and you know exactly where you need to take care of focus and announcements. All parts at a glance are in the htmx series. The accessibility of the page structure itself is covered in the article on HTML landmarks.