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.defaultFocusScroll to false to 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.activeElement and its selectionStart/selectionEnd values in src/htmx.js.
  • Focus restoration requires ID attributes because htmx locates the replacement element using getElementById, then reapplies selection ranges via setSelectionRange.
  • Scroll behavior is configurable through the focusScroll swap option or htmx.config.defaultFocusScroll, passed as preventScroll in the focus options object.
  • The hx-preserve attribute leverages handlePreservedElements() and restorePreservedElements() to move nodes to a hidden pantry (#--htmx-preserve-pantry--), keeping them entirely untouched during swaps.
  • Both mechanisms execute after fragment insertion but before afterSwap events 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →