How htmx Preserves Focus and Text Selection During DOM Swaps
htmx preserves focus and text selection by capturing document.activeElement and its selectionStart/selectionEnd values before swapping DOM content, then restoring those values to the matching element (by ID) after the swap completes.
When htmx performs partial page updates via AJAX, it must prevent the user from losing their cursor position or focus state. According to the bigskysoftware/htmx source code, this is achieved through a sophisticated preservation mechanism that handles both standard focus restoration and the hx-preserve attribute for keeping entire elements intact.
The Two-Step Preservation Process
The core implementation in src/htmx.js uses a capture-and-restore pattern that executes around the swap operation.
Step 1: Capturing Focus State Before the Swap
Just before parsing the response fragment, htmx records the currently focused element and its selection boundaries. This occurs immediately after the swap target is resolved but before any DOM manipulation begins.
// src/htmx.js – preserve focus and selection
const activeElt = document.activeElement;
let selectionInfo = {
elt: activeElt,
// @ts-ignore – selectionStart may be undefined on non‑text inputs
start: activeElt ? activeElt.selectionStart : null,
// @ts-ignore – selectionEnd may be undefined on non‑text inputs
end: activeElt ? activeElt.selectionEnd : null
};
The selectionInfo object stores the active element reference along with its selectionStart and selectionEnd properties. These cursor positions are only valid on text input elements, so the code defensively checks for their existence.
Step 2: Restoring Focus After the Swap
Once the new content is inserted via swapWithStyle (part of the swapOuterHTML or swapInnerHTML routines), htmx attempts to locate the original element within the newly rendered DOM using its id attribute.
// src/htmx.js – apply saved focus and selection information
if (selectionInfo.elt &&
!bodyContains(selectionInfo.elt) &&
getRawAttribute(selectionInfo.elt, 'id')) {
const newActiveElt = document.getElementById(
getRawAttribute(selectionInfo.elt, 'id')
);
const focusOptions = {
preventScroll: swapSpec.focusScroll !== undefined
? !swapSpec.focusScroll
: !htmx.config.defaultFocusScroll
};
if (newActiveElt) {
if (selectionInfo.start && newActiveElt.setSelectionRange) {
try {
newActiveElt.setSelectionRange(selectionInfo.start,
selectionInfo.end);
} catch (e) {
// Some elements expose setSelectionRange but don’t support it
}
}
newActiveElt.focus(focusOptions);
}
}
The restoration only triggers if the original element was removed from the document (!bodyContains) and possesses an id. The focusOptions object respects the focusScroll swap specification or falls back to the global htmx.config.defaultFocusScroll setting.
The hx-preserve Attribute for Complete Element Survival
For elements that must survive a swap entirely—including their internal state and child nodes—htmx provides the hx-preserve attribute. This mechanism functions differently than focus restoration; it physically moves the existing element out of the DOM before the swap and reinserts it afterward.
During the swap preparation phase, handlePreservedElements(fragment) identifies elements marked with hx-preserve and moves them to a hidden temporary container (#--htmx-preserve-pantry--). After the new fragment is inserted, restorePreservedElements() places the preserved nodes back into their original positions, replacing any freshly rendered duplicates.
<div id="editor" hx-get="/update" hx-swap="outerHTML" hx-preserve>
<textarea id="code">User edits remain here</textarea>
</div>
In this example, even when the outer div is replaced via outerHTML, the textarea and its exact cursor position remain untouched because the original node never leaves the document—it simply visits the pantry temporarily.
Controlling Scroll Behavior on Focus Restoration
By default, restoring focus may trigger the browser to scroll the element into view. htmx allows fine-grained control over this behavior through both inline attributes and global configuration.
- Per-element control: Use
hx-focus-scroll="false"to prevent scrolling for a specific swap - Global configuration: Set
htmx.config.defaultFocusScrolltofalseto disable scroll-on-focus application-wide
The focusOptions.preventScroll parameter passed to element.focus() is derived from these settings, ensuring consistent behavior across different swap types including out-of-band swaps.
Practical Implementation Examples
Example 1: Search Input with Live Results
<input id="search"
hx-get="/results"
hx-trigger="keyup changed delay:500ms"
hx-target="#results" />
As the user types, htmx captures the caret position in #search, swaps the results container, and restores the cursor to the exact character position within the input field.
Example 2: Preserving Complex Editor State
<form hx-post="/save" hx-swap="outerHTML">
<div hx-preserve>
<textarea id="comment">Draft content...</textarea>
</div>
<button type="submit">Save</button>
</form>
The textarea maintains focus and selection even when the form receives a full outerHTML replacement after submission.
Example 3: Disabling Focus Scroll
<input id="username"
hx-get="/check-availability"
hx-swap="outerHTML"
hx-focus-scroll="false">
When the input is replaced with validation feedback, the browser will not scroll the page to bring the element into view, preserving the user's reading position.
Summary
- htmx captures focus state immediately before DOM swaps by storing
document.activeElementand itsselectionStart/selectionEndvalues insrc/htmx.js. - Focus restoration requires ID attributes because htmx locates the replacement element using
getElementById, then reapplies selection ranges viasetSelectionRange. - Scroll behavior is configurable through the
focusScrollswap option orhtmx.config.defaultFocusScroll, passed aspreventScrollin the focus options object. - The
hx-preserveattribute leverageshandlePreservedElements()andrestorePreservedElements()to move nodes to a hidden pantry (#--htmx-preserve-pantry--), keeping them entirely untouched during swaps. - Both mechanisms execute after fragment insertion but before
afterSwapevents fire, ensuring consistent state for event handlers.
Frequently Asked Questions
Does htmx preserve focus without an ID on the element?
No. According to the source code in src/htmx.js, htmx only attempts to restore focus if getRawAttribute(selectionInfo.elt, 'id') returns a truthy value. The preservation mechanism relies on document.getElementById to locate the corresponding element in the new DOM fragment. Without an ID, the focus is lost after the swap.
What happens if the preserved element doesn't support text selection?
The code safely handles non-text inputs. When capturing state, selectionStart and selectionEnd may be null or undefined for elements like buttons or checkboxes. During restoration, the code only calls setSelectionRange if selectionInfo.start exists and the new element supports the method. The try...catch block silently handles elements that expose setSelectionRange but do not implement it.
How does hx-preserve differ from standard focus preservation?
Standard focus preservation captures metadata (element reference and cursor position) before the swap and attempts to restore it to a new DOM node. In contrast, hx-preserve physically moves the original DOM node to a temporary container (#--htmx-preserve-pantry--) before the swap occurs, then reinserts that exact same node afterward. This preserves internal state that cannot be captured, such as video playback position or complex editor states beyond simple text selection.
Can I disable focus restoration entirely?
While there is no global toggle to disable focus preservation entirely in the core library, you can effectively prevent focus restoration by ensuring focused elements lack id attributes, though this is not recommended. For scroll behavior specifically, you can set htmx.config.defaultFocusScroll = false or use hx-focus-scroll="false" on individual elements to prevent the browser from scrolling when focus is restored.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →