| name | htmx-expert |
| description | Use when writing, debugging, or reviewing htmx attributes (hx-get, hx-post, hx-swap, hx-target, hx-trigger, hx-boost), AJAX interactions, server-side HTML fragment responses, out-of-band swaps, htmx events, loading indicators, or hypermedia-driven patterns. Also use when the user mentions htmx class names like htmx-indicator, htmx-request, or any hx-* attributes, even if they don't explicitly say "htmx." |
htmx Expert
Target: htmx 2.x — see references/v2-changes.md for migration notes from 1.x.
Core Philosophy
Servers respond with HTML fragments, not JSON. htmx extends HTML to handle AJAX requests, CSS transitions, WebSockets, and Server-Sent Events directly from attributes. The goal is hypermedia-driven applications where the server controls application state and the browser renders HTML.
Core Attributes Reference
HTTP Verb Attributes
| Attribute | Purpose | Default Trigger |
|---|
hx-get | Issue GET request | click |
hx-post | Issue POST request | click (form: submit) |
hx-put | Issue PUT request | click |
hx-patch | Issue PATCH request | click |
hx-delete | Issue DELETE request | click |
hx-boost — Progressive Enhancement in One Attribute
hx-boost="true" on a parent element converts all child links and forms to AJAX requests automatically. This is the easiest way to add htmx to an existing multi-page app — no other attributes needed.
<body hx-boost="true">
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
<form action="/login" method="post">
<input name="user" />
<button type="submit">Login</button>
</form>
</body>
Boosted requests swap the <body> content and push the URL to browser history. To exclude an element: hx-boost="false".
Request Control
- hx-trigger: Customize when requests fire
- Modifiers:
changed, delay:Xms, throttle:Xms, once
- Special triggers:
load, revealed, every Xs
- Extended:
from:<selector>, target:<selector>
- hx-include: Include additional element values in request
- hx-params: Filter which parameters to send (
*, none, not <param>, <param>)
- hx-headers: Add custom headers (JSON format)
- hx-vals: Add values to request (JSON format)
- hx-encoding: Set encoding (
multipart/form-data for file uploads)
Response Handling
- hx-target: Where to place response content
- Extended selectors:
this, closest <sel>, next <sel>, previous <sel>, find <sel>
- hx-swap: How to insert content
innerHTML (default), outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none
- Modifiers:
swap:Xms, settle:Xms, scroll:top, show:top, transition:true
- hx-select: Select subset of response to swap
- hx-select-oob: Select elements for out-of-band swaps
State Management
- hx-push-url: Push URL to browser history
- hx-replace-url: Replace current URL in history
- hx-history: Control history snapshot behavior
- hx-history-elt: Specify element to snapshot
- hx-preserve: Keep an element unchanged during swaps — essential for video/audio players, iframes, or any stateful DOM content. The element must have a stable
id.
UI Indicators
- hx-indicator: Element to show during request (add
htmx-indicator class)
- hx-disabled-elt: Elements to disable during request
Security & Control
-
hx-confirm: Show confirmation dialog before request
-
hx-validate: Enable HTML5 validation on non-form elements
-
hx-disable: Disable htmx processing on element and descendants
-
hx-sync: Coordinate requests between elements to prevent race conditions
<input hx-get="/search" hx-trigger="input changed delay:300ms" hx-sync="this:abort" hx-target="#results" />
<form hx-post="/save" hx-sync="this:queue first">...</form>
<button hx-get="/data" hx-sync="this:drop">Load</button>
Strategies: drop (ignore new), abort (cancel old), replace (cancel old, send new), queue first, queue last, queue all.
Implementation Patterns
Basic AJAX
<button hx-get="/api/data" hx-target="#result" hx-swap="innerHTML">Load Data</button>
<div id="result"></div>
Active Search
<input
type="search"
name="q"
hx-get="/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results"
hx-sync="this:abort" />
<div id="search-results"></div>
Use input changed instead of keyup changed (catches paste, autofill). The search trigger handles the clear button (X). hx-sync="this:abort" cancels stale in-flight requests.
Infinite Scroll
<div hx-get="/items?page=2" hx-trigger="revealed" hx-swap="afterend">Loading more...</div>
Polling
<div hx-get="/status" hx-trigger="every 5s" hx-swap="innerHTML">Status: Unknown</div>
Form Submission
<form hx-post="/submit" hx-target="#response" hx-swap="outerHTML">
<input name="email" type="email" required />
<button type="submit">Submit</button>
</form>
Out-of-Band Updates
Server response can update multiple elements simultaneously:
<div id="main-content">Updated content</div>
<div id="notification" hx-swap-oob="true">New notification!</div>
<span id="counter" hx-swap-oob="true">42</span>
Loading Indicators
<button hx-get="/slow-endpoint" hx-indicator="#spinner">Load</button>
<img id="spinner" class="htmx-indicator" src="/spinner.gif" />
CSS for indicators:
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
CSS-only spinner (preferred over image files):
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3d72d7;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
Form POST with Loading State
Combine hx-indicator and hx-disabled-elt for complete UX:
<form hx-post="/api/submit" hx-target="#result" hx-indicator="#spinner" hx-disabled-elt="find button">
<input name="email" required />
<button type="submit">
Submit
<span id="spinner" class="spinner htmx-indicator"></span>
</button>
</form>
Row Updates with closest
<li id="item-1">
<span>Item 1</span>
<button hx-get="/api/update-item/1" hx-target="closest li" hx-swap="outerHTML">Update</button>
</li>
Server returns complete <li> element with new htmx attributes intact.
Combining Multiple Triggers
<div hx-get="/api/data" hx-trigger="load, every 5s, click from:#refresh-btn"></div>
Template Organization
htmx apps need two versions of most views: a full page (for direct navigation) and a partial fragment (for AJAX). Use the HX-Request header to decide which to render.
if request.headers.get('HX-Request'):
return render_template('_partial.html')
else:
return render_template('full_page.html')
Convention: prefix partial templates with _ (e.g., _search_results.html, _user_row.html) to distinguish them from full-page templates at a glance.
Server Response Patterns
Response Headers
| Header | Purpose |
|---|
HX-Location | Client-side redirect (with context) |
HX-Push-Url | Push URL to history |
HX-Redirect | Full page redirect |
HX-Refresh | Refresh the page |
HX-Reswap | Override hx-swap value |
HX-Retarget | Override hx-target value |
HX-Trigger | Trigger client-side events |
HX-Trigger-After-Settle | Trigger after settle |
HX-Trigger-After-Swap | Trigger after swap |
View Transitions
htmx integrates with the browser's View Transitions API for smooth visual updates during swaps.
<button hx-get="/page" hx-swap="innerHTML transition:true" hx-target="#content">Navigate</button>
<meta name="htmx-config" content='{"globalViewTransitions":true}' />
Style transitions with CSS:
::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-in;
}
Requires browser support (Chrome 111+, Safari 18+). Falls back gracefully — the swap still works, just without the animation.
Events
Key Events
| Event | When Fired |
|---|
htmx:load | Element loaded into DOM |
htmx:configRequest | Before request sent (modify params/headers) |
htmx:beforeRequest | Before AJAX request |
htmx:afterRequest | After AJAX request completes |
htmx:beforeSwap | Before content swap |
htmx:afterSwap | After content swap |
htmx:afterSettle | After DOM settles |
htmx:confirm | Before confirmation dialog |
htmx:validation:validate | Custom validation hook |
Event Handling
Using hx-on: (htmx 2.x syntax — note the colon, then the event with :: prefix for htmx events):
<button hx-get="/data" hx-on::before-request="console.log('Starting...')" hx-on::after-swap="console.log('Done!')">
Load
</button>
<button hx-on:click="console.log('clicked')">Click</button>
Using JavaScript:
document.body.addEventListener("htmx:configRequest", function (evt) {
evt.detail.headers["X-Custom-Header"] = "value";
});
Security Best Practices
- Escape All User Content: Prevent XSS through server-side template escaping
- Use hx-disable: Prevent htmx processing on untrusted content
- Restrict Request Origins:
htmx.config.selfRequestsOnly = true;
- Disable Script Processing:
htmx.config.allowScriptTags = false;
- Include CSRF Tokens:
<body hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'></body>
- Content Security Policy: Layer browser-level protections
Extensions
htmx 2.x ships extensions as separate packages. See references/extensions.md for detailed usage of each.
<script src="https://unpkg.com/htmx-ext-<name>@<version>/<name>.js"></script>
<body hx-ext="extension-name"></body>
Key extensions: idiomorph (morph swaps — preserves focus/form state), sse (Server-Sent Events), ws (WebSockets), head-support (merge <head> changes), response-targets (target by HTTP status), preload (prefetch on hover).
Configuration
htmx.config.defaultSwapStyle = "innerHTML";
htmx.config.timeout = 0;
htmx.config.historyCacheSize = 10;
htmx.config.globalViewTransitions = false;
htmx.config.scrollBehavior = "instant";
htmx.config.selfRequestsOnly = true;
htmx.config.allowScriptTags = false;
htmx.config.allowEval = true;
Or via meta tag: <meta name="htmx-config" content='{"selfRequestsOnly":true}' />
Debugging
htmx.logAll();
Check Network tab headers: HX-Request, HX-Target, HX-Trigger, HX-Current-URL
Third-Party Integration
htmx.onLoad(function (content) {
content.querySelectorAll(".datepicker").forEach((el) => new Datepicker(el));
});
htmx.process(document.getElementById("new-content"));
Common Gotchas
- ID Stability: Keep element IDs stable for CSS transitions and OOB swaps
- Swap Timing: Default 0ms swap delay; use
swap:100ms for transitions
- Event Bubbling: htmx events bubble; use
event.detail for data
- Form Data: Only named inputs are included in requests
- History: History snapshots store innerHTML, not full DOM state
- file:// won't work: htmx requires HTTP — always serve via HTTP server
- hx-on syntax: In htmx 2.x, use
hx-on:click (not hx-on="click: ..."). For htmx events, double colon: hx-on::after-swap
- Extensions are separate: SSE, WebSockets, and other extensions must be loaded as separate scripts in htmx 2.x
Progressive Enhancement
<form action="/search" method="POST">
<input name="q" hx-get="/search" hx-trigger="input changed delay:300ms" hx-target="#results" />
<button type="submit">Search</button>
</form>
<div id="results"></div>
Non-JavaScript users get form submission; JavaScript users get AJAX.