htmx-expert

Installation
SKILL.md

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">
    <!-- All links now use AJAX with push-url, all forms submit via AJAX -->
    <nav>
        <a href="/dashboard">Dashboard</a>  <!-- AJAX GET, swaps body -->
        <a href="/settings">Settings</a>
    </nav>
    <form action="/login" method="post">  <!-- AJAX 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
    <!-- Abort in-flight request when a new one starts (good for search/typeahead) -->
    <input hx-get="/search" hx-trigger="input changed delay:300ms"
           hx-sync="this:abort" hx-target="#results" />
    
    <!-- Queue requests on a form so rapid submits don't race -->
    <form hx-post="/save" hx-sync="this:queue first">...</form>
    
    <!-- Drop new requests while one is in flight -->
    <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:

<!-- Main response (swapped into hx-target as normal) -->
<div id="main-content">Updated content</div>

<!-- OOB updates (swapped by matching id, regardless of hx-target) -->
<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.

<!-- Enable per-swap -->
<button hx-get="/page" hx-swap="innerHTML transition:true" hx-target="#content">Navigate</button>

<!-- Enable globally -->
<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>

<!-- Standard DOM events use single colon -->
<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

  1. Escape All User Content: Prevent XSS through server-side template escaping
  2. Use hx-disable: Prevent htmx processing on untrusted content
  3. Restrict Request Origins: htmx.config.selfRequestsOnly = true;
  4. Disable Script Processing: htmx.config.allowScriptTags = false;
  5. Include CSRF Tokens:
    <body hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'></body>
    
  6. 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"; // or 'smooth', 'auto'
htmx.config.selfRequestsOnly = true; // recommended for security
htmx.config.allowScriptTags = false; // recommended for security
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")); // for programmatically added content

Common Gotchas

  1. ID Stability: Keep element IDs stable for CSS transitions and OOB swaps
  2. Swap Timing: Default 0ms swap delay; use swap:100ms for transitions
  3. Event Bubbling: htmx events bubble; use event.detail for data
  4. Form Data: Only named inputs are included in requests
  5. History: History snapshots store innerHTML, not full DOM state
  6. file:// won't work: htmx requires HTTP — always serve via HTTP server
  7. hx-on syntax: In htmx 2.x, use hx-on:click (not hx-on="click: ..."). For htmx events, double colon: hx-on::after-swap
  8. 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.

Related skills
Installs
4
GitHub Stars
218
First Seen
Mar 30, 2026