name: htmx-guidance description: Use when writing HTML with htmx, building htmx-powered pages, or answering questions about htmx patterns and best practices. Covers htmx 4 attributes, events, swap strategies, and common UI patterns.
htmx 4 Guidance
htmx allows any HTML element to issue HTTP requests and swap the response into the DOM. The server returns HTML fragments, not JSON. This is the fundamental model.
htmx 4 uses the fetch() API (not XMLHttpRequest like htmx 2).
Core Attributes
Issue requests with these attributes. Each takes a URL:
| Attribute | Description |
|---|---|
hx-get | GET request |
hx-post | POST request |
hx-put | PUT request |
hx-patch | PATCH request |
hx-delete | DELETE request |
hx-query | QUERY request |
Default Triggers
input,textarea,selecttrigger onchangeformtriggers onsubmit- Everything else triggers on
click
Override with hx-trigger.
hx-trigger
Specify what event triggers the request:
<div hx-get="/data" hx-trigger="mouseenter">Hover me</div>
Modifiers:
| Modifier | Description |
|---|---|
once | fire only once |
changed | only fire if the value of the element changed |
delay:<time> | debounce, e.g. delay:500ms. A new event resets the countdown |
throttle:<time> | throttle. The first event fires at once, later events wait for the cooldown |
from:<selector> | listen on a different element. Accepts document, window, closest, find, next, previous |
target:<selector> | only fire if event.target matches the selector |
prevent | call event.preventDefault() |
stop | call event.stopPropagation(). consume is a synonym |
halt | shorthand for prevent stop |
capture | listen in the capture phase instead of the bubble phase |
passive | tell the browser the handler will not call preventDefault() |
A selector with whitespace needs parentheses: from:(form input).
Filters (JavaScript expressions in brackets):
<div hx-get="/data" hx-trigger="click[ctrlKey]">Ctrl+Click me</div>
Special events:
load-- fires when element is loadedrevealed-- fires when element scrolls into viewportintersect-- fires on intersection (options:root:<sel>,threshold:<float>)
Polling:
<div hx-get="/updates" hx-trigger="every 2s">Poll</div>
Multiple triggers (comma-separated):
<input hx-get="/search" hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#results">
Triggering from HX-Trigger header -- use from:body:
<div hx-get="/table" hx-trigger="refreshTable from:body">...</div>
hx-target
CSS selector for where the response content goes. Defaults to the element itself.
Extended CSS selectors:
this-- the element with the attributeclosest <selector>-- nearest ancestor matching selectorfind <selector>-- first child descendant matching selectornext [selector]-- next sibling (optionally matching selector)previous [selector]-- previous sibling (optionally matching selector)
The relative selectors can be used to avoid adding ids to DOM elements, for example in a table generated by a loop.
<button hx-get="/data" hx-target="closest .container">Load</button>
hx-swap
Controls how response content is placed relative to the target. Default: innerHTML.
| Value | Description |
|---|---|
innerHTML | Replace inner HTML of target |
outerHTML | Replace entire target element |
outerSync | Morph the target's attributes, then replace its children. Target stays in DOM |
innerMorph | Morph children of target (preserves DOM state) |
outerMorph | Morph target itself (preserves DOM state) |
textContent | Replace text content, no HTML parsing |
before / beforebegin | Insert before the target |
prepend / afterbegin | Insert before target's first child |
append / beforeend | Insert after target's last child |
after / afterend | Insert after the target |
delete | Delete the target regardless of response |
none | Don't swap (OOB swaps and headers still processed) |
Modifiers (space-separated after swap style):
<div hx-get="/data" hx-swap="innerHTML swap:100ms settle:200ms transition:true ignoreTitle:true scroll:top">
| Modifier | Description |
|---|---|
swap:<time> | Delay before swap |
settle:<time> | Delay between swap and settle |
transition:true | Use View Transitions API |
ignoreTitle:true | Don't update page title from response |
scroll:top/bottom | Scroll target after swap |
show:top/bottom | Scroll target into viewport |
scrollTarget:<selector> | Scroll this element instead of the target |
showTarget:<selector> | Scroll this element into view instead of the target |
strip:true | Remove outer wrapper element before swapping |
focusScroll:true | Scroll to focused element |
swapEmpty:true | Run the main swap even when nothing remains after OOB and partial content is removed |
target:<selector> | Retarget the swap |
Attribute Inheritance (CRITICAL htmx 4 change)
In htmx 4, inheritance is explicit by default. Use the :inherited modifier on parent elements:
<!-- WRONG in htmx 4: children won't inherit hx-target -->
<div hx-target="#output">
<button hx-get="/a">A</button>
<button hx-get="/b">B</button>
</div>
<!-- CORRECT: use :inherited modifier -->
<div hx-target:inherited="#output">
<button hx-get="/a">A</button>
<button hx-get="/b">B</button>
</div>
The :append modifier appends to inherited values:
<div hx-include:inherited="[name='token']">
<button hx-post="/save" hx-include:inherited:append="[name='extra']">Save</button>
</div>
To revert to implicit inheritance globally: set htmx.config.implicitInheritance = true.
Configuration
Set via meta tag or JavaScript:
<meta name="htmx-config" content='{"defaultSwap":"outerHTML"}'>
Key config values:
| Config | Default | Description |
|---|---|---|
defaultSwap | innerHTML | Default swap strategy |
defaultTimeout | 60000 | Request timeout (ms) |
defaultSettleDelay | 1 | Delay in ms between swap and settle |
defaultFocusScroll | false | Scroll focused elements into view after a swap |
noSwap | [204, 304] | Status codes that skip swapping |
allowEmptySwapAfterOOB | false | Run the main swap when the response holds only OOB or partial content |
implicitInheritance | false | Auto-inherit attributes from parents |
transitions | false | Enable View Transitions globally |
logAll | false | Log every event to console (debugging) |
mode | same-origin | Fetch mode (cors, no-cors, same-origin) |
history | true | Enable history support (true, false, "reload") |
extensions | "" | Whitelist of allowed extensions. Empty allows all |
prefix | "data-hx-" | Second attribute prefix, checked in addition to hx- |
metaCharacter | unset, acts as : | Character that introduces an attribute modifier |
indicatorClass | htmx-indicator | Class on elements that show during a request |
requestClass | htmx-request | Class added while a request is in flight |
includeIndicatorCSS | true | Inject the default indicator stylesheet |
inlineScriptNonce | unset | Nonce added to scripts htmx inserts |
morphIgnore | ["data-htmx-powered"] | Attribute name prefixes to leave unchanged when morphing |
morphScanLimit | 10 | Sibling scan limit during morphing |
morphSkip | '[hx-morph-skip]' | CSS selector for elements to skip morphing entirely |
morphSkipChildren | '[hx-morph-skip-children]' | CSS selector for elements whose children skip morphing |
prefix is additive, not a replacement. hx-get and data-hx-get both work out of the box.
Config values use HCON, htmx's configuration object notation. HCON accepts JSON, but also a shorter form:
<meta name="htmx-config" content="defaultSwap:outerHTML, logAll:true">
Events
htmx 4 naming convention: htmx:phase:action
Element lifecycle:
htmx:before:process/htmx:after:process-- htmx scans a subtreehtmx:before:init/htmx:after:init-- element initializationhtmx:before:cleanup/htmx:after:cleanup-- element removalhtmx:before:on:init-- before anhx-onhandler is installed
Request:
htmx:confirm-- after trigger, before request. Detail holdsissueRequestanddropRequestfor async confirmationhtmx:config:request-- configure request (modify headers, body, URL). Cancel withevt.preventDefault()htmx:before:request-- just before fetch. Cancel withevt.preventDefault()htmx:before:response-- after fetch response received, before body consumedhtmx:after:request-- after request completeshtmx:finally:request-- when request completes, fails, or is cancelledhtmx:error-- on any error (network, response, swap)htmx:response:error-- the server returned an HTTP error status
Swap:
htmx:before:swap/htmx:after:swap-- before/after content swaphtmx:finally:swap-- after the swap, on success or errorhtmx:before:settle/htmx:after:settle-- before/after settle phase
History:
htmx:before:history:update/htmx:after:history:updatehtmx:after:history:push/htmx:after:history:replacehtmx:before:history:restore
View Transitions:
htmx:before:viewTransition/htmx:after:viewTransition
Aborting a request: htmx:abort is an event you dispatch, not one htmx fires. Send it at an element to
cancel that element's in-flight requests:
htmx.trigger("#slow-thing", "htmx:abort");
A few hooks are delivered to extensions only and never reach the DOM: htmx:before:morph:node,
htmx:before:morph:attr, htmx:after:implicitInheritance and htmx:process:<type>.
Request Context
Events expose detail.ctx with the full request context:
document.body.addEventListener('htmx:config:request', (evt) => {
let ctx = evt.detail.ctx;
// ctx.sourceElement -- element that triggered request
// ctx.target -- swap target element
// ctx.swap -- hx-swap value
// ctx.request.action -- URL
// ctx.request.method -- HTTP method
// ctx.request.headers -- headers object
// ctx.request.body -- FormData body
});
Inline Event Handlers
Use hx-on:event-name for inline handlers:
<button hx-get="/data" hx-on:htmx:after:swap="alert('Swapped!')">Load</button>
HTTP Headers
Request Headers (sent by htmx)
| Header | Description |
|---|---|
HX-Request | Always "true" for htmx requests |
HX-Source | Triggering element as tag#id (e.g. button#submit) |
HX-Target | Target element as tag#id (e.g. div#results) |
HX-Current-URL | Browser's current URL |
HX-Request-Type | "partial" for targeted swaps, "full" when targeting body or using hx-select |
HX-Boosted | "true" if via hx-boost |
HX-History-Restore-Request | "true" if restoring history |
Response Headers (server sends to htmx)
| Header | Description |
|---|---|
HX-Trigger | Trigger client-side events (single name or JSON) |
HX-Push-Url | Push URL to browser history |
HX-Replace-Url | Replace current URL in history |
HX-Redirect | Client-side redirect (full page) |
HX-Location | Client-side redirect via AJAX (no full reload) |
HX-Refresh | Full page refresh if "true" |
HX-Retarget | Override target with CSS selector |
HX-Reswap | Override swap strategy |
HX-Reselect | Override hx-select |
Status-Based Response Handling (hx-status)
Handle different HTTP status codes with different swap behavior:
<form hx-post="/register"
hx-target="#result"
hx-status:422="target:#errors select:#validation-errors"
hx-status:5xx="swap:none">
<input name="email" type="email">
<div id="errors"></div>
<div id="result"></div>
<button type="submit">Register</button>
</form>
Supports wildcards: hx-status:5xx, hx-status:50x, hx-status:404.
Config options in the value: swap:, target:, select:, push:, replace:, transition:.
Updating Multiple Page Regions
Three main approaches:
1. Expand the Target
Wrap both regions in a container and target it:
<div id="page-section">
<div id="table">...</div>
<form hx-post="/contacts" hx-target="#page-section">...</form>
</div>
Server returns both the table and the form.
2. Out-of-Band Swaps
Server response includes extra elements with hx-swap-oob:
<!-- Main response content (swapped into target normally) -->
<form>...</form>
<!-- This gets swapped into #contacts-table by ID -->
<tbody hx-swap-oob="beforeend:#contacts-table">
<tr>
<td>New row</td>
</tr>
</tbody>
Note: in htmx 4, OOB swaps happen AFTER the main content swap.
3. Partial Tags
New in htmx 4, a more general version of OOB swaps
<hx-partial hx-target="#messages" hx-swap="beforeend">
<div>New message</div>
</hx-partial>
<hx-partial hx-target="#notifications" hx-swap="innerHTML">
<span class="badge">5</span>
</hx-partial>
Each <hx-partial> specifies its own target and swap strategy. Preferred over OOB for explicit targeting.
4. Event-Driven Refresh
Server sends HX-Trigger: newContact header. Table listens for the event:
<tbody id="contacts-table"
hx-get="/contacts/table"
hx-trigger="newContact from:body">
...
</tbody>
Morphing
innerMorph and outerMorph merge new content into the existing DOM instead of replacing it.
Preserves: focus, scroll position, CSS animations, event listeners, playing video, form input values.
ID matching is highest priority -- elements with matching IDs are updated in place.
Warning: morphing preserves user input values. It cannot be used to reset forms -- use innerHTML/outerHTML for
that.
Excluding elements from morphing -- add attributes to your server templates:
<!-- freeze entire element: attrs + children unchanged -->
<custom-widget hx-morph-skip>...</custom-widget>
<!-- freeze only children: attrs still update -->
<lit-component hx-morph-skip-children>...</lit-component>
Or set CSS selectors globally in config:
htmx.config.morphSkip = 'custom-widget, .frozen';
htmx.config.morphSkipChildren = 'lit-component, .sortable';
Other Attributes
| Attribute | Description |
|---|---|
hx-select | CSS selector to pick part of the response |
hx-select-oob | Pick out elements by ID for OOB swap |
hx-include | Include additional elements' values in request |
hx-vals | Add values to request. Supports js: prefix for dynamic values |
hx-headers | Add custom headers to request |
hx-indicator | Element to show during request (gets htmx-request class) |
hx-confirm | Show confirmation dialog. Supports js: prefix for async confirmation |
hx-sync | Synchronize requests between elements |
hx-boost | Progressive enhancement for links and forms |
hx-config | Per-element Fetch config (timeout, credentials, cache, etc.). Cannot override mode |
hx-preserve | Keep element unchanged across swaps |
hx-ignore | Disable htmx processing for element and children |
hx-disable | Disable specified elements during requests |
hx-preload | Preload content on trigger events |
hx-pending | Show pending content during request |
hx-push-url | Push URL to browser history |
hx-replace-url | Replace URL in browser history |
hx-encoding | Change encoding (e.g. multipart/form-data for file uploads) |
hx-validate | Validate form elements before request |
hx-action | Request URL, when the method comes from hx-method |
hx-method | HTTP method, paired with hx-action |
hx-status:XXX | Change target, swap or history handling for one status code |
hx-history-elt | Element to restore on history navigation, instead of body |
hx-morph-skip | Freeze this element during a morph swap |
hx-morph-skip-children | Update attributes but freeze children during a morph swap |
Parameters
- Non-GET/DELETE requests automatically include enclosing form values
- GET and DELETE do NOT include enclosing form data. Use
hx-include="closest form"if needed - Use
hx-vals="key:value"for static values.hx-valstakes HCON, which also accepts JSON - Use
hx-vals='js:{"key": computeValue()}'for dynamic values hx-headersandhx-configtake HCON too
JavaScript API
htmx.version // Version string, read-only
htmx.ajax("GET", "/data", {target: "#result"}) // Programmatic request, returns Promise
htmx.on("htmx:after:swap", (evt) => {}) // Event listener
htmx.onLoad((elt) => {}) // Callback for new content
htmx.process(element) // Initialize htmx on dynamic content
htmx.initialize() // Set up history and process document.body
htmx.find("closest .container") // Extended CSS selector query
htmx.findAll(".items") // Find all matching
htmx.trigger(elt, "myEvent", {detail: ...}) // Fire custom event
htmx.swap(ctx) // Manual swap
htmx.timeout(1000) // Promise that resolves after a delay
htmx.parseInterval("2s") // Parse a time interval to ms
htmx.registerExtension("name", hooks) // Register an extension
The hx-live extension adds an htmx.live namespace: take(), toggle(), attr(), q() (alias $),
debounce(), refresh(), forEvent() and nextFrame().
Common Patterns
Active Search
<input type="text" name="q"
hx-get="/search"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#search-results"
hx-indicator="#spinner">
<span id="spinner" class="htmx-indicator">Searching...</span>
<div id="search-results"></div>
Lazy Loading
<div hx-get="/lazy-content" hx-trigger="load" hx-swap="outerHTML">
Loading...
</div>
Infinite Scroll
<tr hx-get="/page/3" hx-trigger="revealed" hx-swap="afterend">
<!-- last row of current page -->
</tr>
Click to Load More
<button hx-get="/page/2" hx-target="#results" hx-swap="beforeend">
Load More
</button>
Edit in Place
<div hx-get="/contact/1/edit" hx-trigger="click" hx-swap="outerHTML">
<p>Click to edit</p>
</div>
Server returns an edit form. Form submits via hx-post and returns the display view.
Tabs
<div role="tablist" hx-target:inherited="#tab-content">
<button role="tab" hx-get="/tab/1" class="active">Tab 1</button>
<button role="tab" hx-get="/tab/2">Tab 2</button>
</div>
<div id="tab-content">...</div>
Form Validation
<form hx-post="/register"
hx-target="#result"
hx-status:422="target:#errors">
<input name="email" type="email">
<div id="errors"></div>
<div id="result"></div>
<button type="submit">Register</button>
</form>
Server returns 422 with error HTML, target becomes the element with the errors id, or 200 with success HTML target is
the element with the id result.
Loading Indicators
<button hx-get="/slow" hx-indicator="#loading">
Load
<img id="loading" class="htmx-indicator" src="/spinner.gif" alt="Loading...">
</button>
The htmx-indicator class hides the element by default (opacity: 0). When a request is in flight, htmx-request class
is added, making indicators visible.
To avoid flashing the spinner on fast requests, add a transition-delay (the second time value) to the indicator's CSS:
.htmx-request .htmx-indicator { transition: opacity 200ms ease-in 200ms; }
If the request finishes before the delay elapses, the spinner never appears
Disabling Elements During Request
<form hx-post="/save" hx-disable="find button, find input">
<input name="data">
<button type="submit">Save</button>
</form>
Extensions
Extensions are loaded by including the script file. They apply page-wide automatically:
<script src="/path/to/hx-preload.js"></script>
To restrict which extensions can load, use the extensions config as a whitelist. The whitelist takes the
registration name, which is not always the file name:
<meta name="htmx-config" content='{"extensions": "preload"}'>
Shipped extensions and their registration names:
| File | Registers as | Purpose |
|---|---|---|
hx-multipart.js | hx-multipart | Stream HTML with multipart/mixed |
hx-sse.js | sse | Stream HTML with text/event-stream (SSE) |
hx-ws.js | ws | Stream HTML and send data over WebSockets |
hx-browser-indicator.js | browser-indicator | Show the browser tab's own spinner |
hx-live.js | hx-live | DOM-based reactive scripting |
hx-pending.js | hx-pending | Show custom content during requests |
hx-prompt.js | hx-prompt | Restores htmx 2's hx-prompt |
hx-preload.js | preload | Preload on hover or other triggers |
hx-history-cache.js | history-cache | Restore back/forward pages from sessionStorage |
hx-ptag.js | ptag | Skip unchanged polls with HX-PTag |
hx-download.js | download | Download files with hx-swap="download" |
hx-head.js | hx-head | Merge <head> tags with hx-head="merge" |
hx-targets.js | hx-targets | Target many elements with hx-targets |
hx-upsert.js | upsert | Update or insert elements with hx-swap="upsert" |
htmx-2-compat.js | compat | Restore htmx 2 defaults and event names |
hx-alpine-compat.js | alpine-compat | Run htmx alongside Alpine.js without conflicts |
hx-csp.js | hx-csp | Make htmx work under a strict Content Security Policy |
htmax.js bundles htmx with the most popular extensions in one file.
htmx 2 vs htmx 4: Practical Differences
If you're unsure which version a project uses, check for fetch() usage in htmx source, the :inherited
modifier on attributes, or colon-separated event names like htmx:after:swap. These are all htmx 4 indicators.
Attributes
| htmx 2 | htmx 4 | Notes |
|---|---|---|
hx-disabled-elt | hx-disable | Renamed |
hx-disable (stops htmx processing) | hx-ignore | Different purpose in each version |
hx-ext="my-ext" | Just include the script file | No attribute needed; config whitelist is optional |
hx-request='{"timeout":5000}' | hx-config='{"timeout":5000}' | Renamed |
hx-prompt="Enter value" | hx-prompt extension (same syntax), or hx-on::config:request one-liner | Restored via extension |
hx-disinherit="*" | Not needed | Inheritance is explicit by default in htmx 4 |
hx-vars | hx-vals with js: prefix | hx-vars removed |
| Attributes inherit implicitly | Must use :inherited modifier | hx-target:inherited="#out" |
data-hx-get works automatically | data-hx-get still works | config.prefix defaults to "data-hx-" |
htmx 4 adds: hx-action, hx-method, hx-config, hx-status:XXX, hx-partial, :inherited and :append modifiers.
Events
htmx 2 uses camelCase: htmx:afterSwap, htmx:beforeRequest, htmx:configRequest.
htmx 4 uses colons: htmx:after:swap, htmx:before:request, htmx:config:request.
Most error events (htmx:sendError, htmx:swapError, htmx:targetError, htmx:timeout) are consolidated into
htmx:error in htmx 4. HTTP error responses fire htmx:response:error (replacing htmx:responseError).
Configuration
| htmx 2 | htmx 4 | Notes |
|---|---|---|
htmx.config.defaultSwapStyle | htmx.config.defaultSwap | Renamed |
htmx.config.timeout = 0 | htmx.config.defaultTimeout = 60000 | Renamed + default changed to 60s |
htmx.config.globalViewTransitions | htmx.config.transitions | Renamed |
htmx.config.historyEnabled | htmx.config.history | Renamed |
htmx.config.selfRequestsOnly | htmx.config.mode = 'same-origin' | Different mechanism |
responseHandling array | htmx.config.noSwap + hx-status | Simpler model |
| 4xx/5xx don't swap by default | All status codes swap except 204/304 | Major behavior change |
| History stored in localStorage | History does full page refresh | No more localStorage snapshots |
JavaScript API
| htmx 2 | htmx 4 | Notes |
|---|---|---|
htmx.defineExtension() | htmx.registerExtension() | Renamed |
htmx.addClass(), htmx.removeClass(), etc. | Native DOM methods | Removed; use element.classList |
htmx.off() | removeEventListener() | Removed; use native |
htmx.remove() | element.remove() | Removed; use native |
htmx.swap(target, content, spec) | htmx.swap(ctx) | Signature changed |
htmx 4 adds: htmx.timeout(). Logging now goes directly to console.error / console.warn / console.log (gated by config.logAll for events). htmx.takeClass() is removed; use htmx.live.take() (provided by the hx-live extension) or the unprefixed take helper inside expression scope. The hx-live extension also exposes htmx.live.forEvent(), htmx.live.nextFrame(), htmx.live.q(), htmx.live.debounce(), htmx.live.refresh().
Swap Styles
htmx 4 adds innerMorph, outerMorph, textContent, and shorthand names (before, after, prepend, append).
HTTP Headers
| htmx 2 | htmx 4 | Notes |
|---|---|---|
HX-Trigger (request header) | HX-Source | Renamed; format changed from ID to tag#id |
HX-Trigger-Name | Removed | Use HX-Source |
HX-Trigger-After-Swap (response) | Removed | Use HX-Trigger |
HX-Trigger-After-Settle (response) | Removed | Use HX-Trigger |
htmx 4 adds: HX-Request-Type ("full" or "partial").
Extensions
htmx 2: hx-ext="my-extension" attribute on elements, htmx.defineExtension("name", {onEvent: ...}).
htmx 4: Just include the script. htmx.registerExtension("name", {htmx_before_request: ...}). Config whitelist optional.
Hook names use underscores (htmx_before_swap) instead of a single onEvent callback.
Instructions for Claude
When generating htmx code:
- The general vibe with htmx is simplicity: a request returns HTML that is inserted into the DOM
- Use
:inheritedmodifier for any attribute on a parent element intended for children - Server endpoints must return HTML fragments, not JSON
- Add loading indicators for requests that may take time (
hx-indicator+ element withhtmx-indicatorclass) - Use
hx-status:422for validation error handling -- server returns 422 with error HTML - Use morph swaps when preserving form/input state matters. Use
innerHTML/outerHTMLfor clean replacement - Prefer
<hx-partial>tags overhx-swap-oobfor multi-region updates (more explicit) - GET and DELETE don't include form data -- use
hx-include="closest form"if needed - When showing patterns, include both the HTML and describe what the server endpoint should return
- There are many useful extensions, for example
hx-sse.jsfor Server-Sent Events andhx-preload.jsfor faster navigation. Suggest them if they make sense.