Skip to content

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:

AttributeDescription
hx-getGET request
hx-postPOST request
hx-putPUT request
hx-patchPATCH request
hx-deleteDELETE request
hx-queryQUERY request

Default Triggers

  • input, textarea, select trigger on change
  • form triggers on submit
  • 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:

ModifierDescription
oncefire only once
changedonly 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
preventcall event.preventDefault()
stopcall event.stopPropagation(). consume is a synonym
haltshorthand for prevent stop
capturelisten in the capture phase instead of the bubble phase
passivetell 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 loaded
  • revealed -- fires when element scrolls into viewport
  • intersect -- 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 attribute
  • closest <selector> -- nearest ancestor matching selector
  • find <selector> -- first child descendant matching selector
  • next [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.

ValueDescription
innerHTMLReplace inner HTML of target
outerHTMLReplace entire target element
outerSyncMorph the target's attributes, then replace its children. Target stays in DOM
innerMorphMorph children of target (preserves DOM state)
outerMorphMorph target itself (preserves DOM state)
textContentReplace text content, no HTML parsing
before / beforebeginInsert before the target
prepend / afterbeginInsert before target's first child
append / beforeendInsert after target's last child
after / afterendInsert after the target
deleteDelete the target regardless of response
noneDon'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">
ModifierDescription
swap:<time>Delay before swap
settle:<time>Delay between swap and settle
transition:trueUse View Transitions API
ignoreTitle:trueDon't update page title from response
scroll:top/bottomScroll target after swap
show:top/bottomScroll target into viewport
scrollTarget:<selector>Scroll this element instead of the target
showTarget:<selector>Scroll this element into view instead of the target
strip:trueRemove outer wrapper element before swapping
focusScroll:trueScroll to focused element
swapEmpty:trueRun 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:

ConfigDefaultDescription
defaultSwapinnerHTMLDefault swap strategy
defaultTimeout60000Request timeout (ms)
defaultSettleDelay1Delay in ms between swap and settle
defaultFocusScrollfalseScroll focused elements into view after a swap
noSwap[204, 304]Status codes that skip swapping
allowEmptySwapAfterOOBfalseRun the main swap when the response holds only OOB or partial content
implicitInheritancefalseAuto-inherit attributes from parents
transitionsfalseEnable View Transitions globally
logAllfalseLog every event to console (debugging)
modesame-originFetch mode (cors, no-cors, same-origin)
historytrueEnable history support (true, false, "reload")
extensions""Whitelist of allowed extensions. Empty allows all
prefix"data-hx-"Second attribute prefix, checked in addition to hx-
metaCharacterunset, acts as :Character that introduces an attribute modifier
indicatorClasshtmx-indicatorClass on elements that show during a request
requestClasshtmx-requestClass added while a request is in flight
includeIndicatorCSStrueInject the default indicator stylesheet
inlineScriptNonceunsetNonce added to scripts htmx inserts
morphIgnore["data-htmx-powered"]Attribute name prefixes to leave unchanged when morphing
morphScanLimit10Sibling 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 subtree
  • htmx:before:init / htmx:after:init -- element initialization
  • htmx:before:cleanup / htmx:after:cleanup -- element removal
  • htmx:before:on:init -- before an hx-on handler is installed

Request:

  • htmx:confirm -- after trigger, before request. Detail holds issueRequest and dropRequest for async confirmation
  • htmx:config:request -- configure request (modify headers, body, URL). Cancel with evt.preventDefault()
  • htmx:before:request -- just before fetch. Cancel with evt.preventDefault()
  • htmx:before:response -- after fetch response received, before body consumed
  • htmx:after:request -- after request completes
  • htmx:finally:request -- when request completes, fails, or is cancelled
  • htmx: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 swap
  • htmx:finally:swap -- after the swap, on success or error
  • htmx:before:settle / htmx:after:settle -- before/after settle phase

History:

  • htmx:before:history:update / htmx:after:history:update
  • htmx:after:history:push / htmx:after:history:replace
  • htmx: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)

HeaderDescription
HX-RequestAlways "true" for htmx requests
HX-SourceTriggering element as tag#id (e.g. button#submit)
HX-TargetTarget element as tag#id (e.g. div#results)
HX-Current-URLBrowser'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)

HeaderDescription
HX-TriggerTrigger client-side events (single name or JSON)
HX-Push-UrlPush URL to browser history
HX-Replace-UrlReplace current URL in history
HX-RedirectClient-side redirect (full page)
HX-LocationClient-side redirect via AJAX (no full reload)
HX-RefreshFull page refresh if "true"
HX-RetargetOverride target with CSS selector
HX-ReswapOverride swap strategy
HX-ReselectOverride 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

AttributeDescription
hx-selectCSS selector to pick part of the response
hx-select-oobPick out elements by ID for OOB swap
hx-includeInclude additional elements' values in request
hx-valsAdd values to request. Supports js: prefix for dynamic values
hx-headersAdd custom headers to request
hx-indicatorElement to show during request (gets htmx-request class)
hx-confirmShow confirmation dialog. Supports js: prefix for async confirmation
hx-syncSynchronize requests between elements
hx-boostProgressive enhancement for links and forms
hx-configPer-element Fetch config (timeout, credentials, cache, etc.). Cannot override mode
hx-preserveKeep element unchanged across swaps
hx-ignoreDisable htmx processing for element and children
hx-disableDisable specified elements during requests
hx-preloadPreload content on trigger events
hx-pendingShow pending content during request
hx-push-urlPush URL to browser history
hx-replace-urlReplace URL in browser history
hx-encodingChange encoding (e.g. multipart/form-data for file uploads)
hx-validateValidate form elements before request
hx-actionRequest URL, when the method comes from hx-method
hx-methodHTTP method, paired with hx-action
hx-status:XXXChange target, swap or history handling for one status code
hx-history-eltElement to restore on history navigation, instead of body
hx-morph-skipFreeze this element during a morph swap
hx-morph-skip-childrenUpdate 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-vals takes HCON, which also accepts JSON
  • Use hx-vals='js:{"key": computeValue()}' for dynamic values
  • hx-headers and hx-config take 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:

FileRegisters asPurpose
hx-multipart.jshx-multipartStream HTML with multipart/mixed
hx-sse.jssseStream HTML with text/event-stream (SSE)
hx-ws.jswsStream HTML and send data over WebSockets
hx-browser-indicator.jsbrowser-indicatorShow the browser tab's own spinner
hx-live.jshx-liveDOM-based reactive scripting
hx-pending.jshx-pendingShow custom content during requests
hx-prompt.jshx-promptRestores htmx 2's hx-prompt
hx-preload.jspreloadPreload on hover or other triggers
hx-history-cache.jshistory-cacheRestore back/forward pages from sessionStorage
hx-ptag.jsptagSkip unchanged polls with HX-PTag
hx-download.jsdownloadDownload files with hx-swap="download"
hx-head.jshx-headMerge <head> tags with hx-head="merge"
hx-targets.jshx-targetsTarget many elements with hx-targets
hx-upsert.jsupsertUpdate or insert elements with hx-swap="upsert"
htmx-2-compat.jscompatRestore htmx 2 defaults and event names
hx-alpine-compat.jsalpine-compatRun htmx alongside Alpine.js without conflicts
hx-csp.jshx-cspMake 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 2htmx 4Notes
hx-disabled-elthx-disableRenamed
hx-disable (stops htmx processing)hx-ignoreDifferent purpose in each version
hx-ext="my-ext"Just include the script fileNo 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-linerRestored via extension
hx-disinherit="*"Not neededInheritance is explicit by default in htmx 4
hx-varshx-vals with js: prefixhx-vars removed
Attributes inherit implicitlyMust use :inherited modifierhx-target:inherited="#out"
data-hx-get works automaticallydata-hx-get still worksconfig.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 2htmx 4Notes
htmx.config.defaultSwapStylehtmx.config.defaultSwapRenamed
htmx.config.timeout = 0htmx.config.defaultTimeout = 60000Renamed + default changed to 60s
htmx.config.globalViewTransitionshtmx.config.transitionsRenamed
htmx.config.historyEnabledhtmx.config.historyRenamed
htmx.config.selfRequestsOnlyhtmx.config.mode = 'same-origin'Different mechanism
responseHandling arrayhtmx.config.noSwap + hx-statusSimpler model
4xx/5xx don't swap by defaultAll status codes swap except 204/304Major behavior change
History stored in localStorageHistory does full page refreshNo more localStorage snapshots

JavaScript API

htmx 2htmx 4Notes
htmx.defineExtension()htmx.registerExtension()Renamed
htmx.addClass(), htmx.removeClass(), etc.Native DOM methodsRemoved; 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 2htmx 4Notes
HX-Trigger (request header)HX-SourceRenamed; format changed from ID to tag#id
HX-Trigger-NameRemovedUse HX-Source
HX-Trigger-After-Swap (response)RemovedUse HX-Trigger
HX-Trigger-After-Settle (response)RemovedUse 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:

  1. The general vibe with htmx is simplicity: a request returns HTML that is inserted into the DOM
  2. Use :inherited modifier for any attribute on a parent element intended for children
  3. Server endpoints must return HTML fragments, not JSON
  4. Add loading indicators for requests that may take time (hx-indicator + element with htmx-indicator class)
  5. Use hx-status:422 for validation error handling -- server returns 422 with error HTML
  6. Use morph swaps when preserving form/input state matters. Use innerHTML/outerHTML for clean replacement
  7. Prefer <hx-partial> tags over hx-swap-oob for multi-region updates (more explicit)
  8. GET and DELETE don't include form data -- use hx-include="closest form" if needed
  9. When showing patterns, include both the HTML and describe what the server endpoint should return
  10. There are many useful extensions, for example hx-sse.js for Server-Sent Events and hx-preload.js for faster navigation. Suggest them if they make sense.