| name | htmx |
| description | This skill should be used when users need help with htmx development, including implementing AJAX interactions, understanding htmx attributes (hx-get, hx-post, hx-swap, hx-target, hx-trigger), debugging htmx behavior, building hypermedia-driven applications, or following htmx best practices. Use when users ask about htmx patterns, server-side HTML responses, or transitioning from SPA frameworks to htmx. (user) |
htmx
This skill provides comprehensive guidance for htmx development, the library that extends HTML to access modern browser features directly without JavaScript.
Core Philosophy
htmx represents a paradigm shift toward hypermedia-first web development. Instead of treating HTML as a presentation layer with JSON APIs, htmx extends HTML to handle AJAX requests, CSS transitions, WebSockets, and Server-Sent Events directly. Servers respond with HTML fragments, not JSON.
When to Use This Skill
- Implementing htmx attributes and interactions
- Building hypermedia-driven applications
- Debugging htmx request/response cycles
- Converting SPA patterns to htmx approaches
- Understanding htmx events and lifecycle
- Configuring htmx extensions
- Implementing proper security measures
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 |
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
-
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
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
Implementation Patterns
Basic AJAX Pattern
<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:500ms, search"
hx-target="#search-results"
hx-select="#search-results"
hx-swap="innerHTML"
hx-push-url="true">
<div id="search-results"></div>
Use input changed instead of keyup changed (catches paste, autofill). The search trigger handles the clear button (X).
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:
<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;
}
Server Response Patterns
Return HTML Fragments
Server endpoints return HTML, not JSON:
use axum::{extract::State, response::{Html, IntoResponse}};
use askama::Template;
use sqlx::PgPool;
async fn list_items(State(pool): State<PgPool>) -> impl IntoResponse {
let items = fetch_items(&pool).await.unwrap_or_default();
let template = ItemsListTemplate { items };
Html(template.render().unwrap())
}
Response Headers
htmx recognizes special 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 |
Detect htmx Requests
Check the HX-Request header to return partials for htmx and full pages for regular navigation:
use axum::{http::HeaderMap, response::{Html, IntoResponse}};
async fn list_items(headers: HeaderMap) -> impl IntoResponse {
let items = get_items().await;
let is_htmx = headers.get("HX-Request").is_some();
if is_htmx {
Html(ItemsPartial { items }.render().unwrap())
} else {
Html(ItemsFullPage { items }.render().unwrap())
}
}
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*:
<button hx-get="/data"
hx-on:htmx:before-request="console.log('Starting...')"
hx-on:htmx:after-swap="console.log('Done!')">
Load
</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 }}"}'>
- Content Security Policy: Layer browser-level protections
Configuration
Key htmx.config options:
htmx.config.defaultSwapStyle = 'innerHTML';
htmx.config.timeout = 0;
htmx.config.historyCacheSize = 10;
htmx.config.globalViewTransitions = false;
htmx.config.scrollBehavior = 'instant';
htmx.config.selfRequestsOnly = false;
htmx.config.allowScriptTags = true;
htmx.config.allowEval = true;
Or via meta tag:
<meta name="htmx-config" content='{"selfRequestsOnly":true}'>
Extensions
Loading Extensions
<script src="https://unpkg.com/htmx-ext-<name>@<version>/<name>.js"></script>
<body hx-ext="extension-name">
Common Extensions
- head-support: Merge head tag information across requests
- idiomorph: Morphing swaps (preserves element state)
- sse: Server-Sent Events support
- ws: WebSocket support
- preload: Content preloading
- response-targets: HTTP status-based targeting
Debugging
Enable logging:
htmx.logAll();
Check request headers in Network tab:
HX-Request: true
HX-Target: <target-id>
HX-Trigger: <trigger-id>
HX-Current-URL: <page-url>
Progressive Enhancement
Structure for graceful degradation:
<form action="/search" method="POST">
<input name="q"
hx-get="/search"
hx-trigger="keyup 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.
Third-Party Integration
Initialize libraries on htmx-loaded content:
htmx.onLoad(function(content) {
content.querySelectorAll('.datepicker').forEach(el => {
new Datepicker(el);
});
});
For programmatically added htmx content:
htmx.process(document.getElementById('new-content'));
Common Gotchas
- ID Stability: Keep element IDs stable for CSS transitions
- 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
Development Environment
Running the Dev Server
just dev
Access at http://localhost:3000. htmx requires HTTP — it will not work from file:// URLs.
htmx Requires HTTP (Not file://)
htmx will NOT work when opening HTML files directly from the filesystem (file:// URLs). This causes htmx:invalidPath errors because:
- Browsers block cross-origin requests from
file:// URLs
- htmx needs to make HTTP requests to endpoints
Practical Implementation Lessons
Loading Indicators with CSS Spinner
Use CSS-only spinners instead of image files for better performance:
<button hx-get="/api/slow"
hx-indicator="#spinner">
Load
<span id="spinner" class="spinner htmx-indicator"></span>
</button>
<style>
.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); }
}
</style>
Input Search with Proper Trigger
Use input changed instead of keyup changed for better UX (catches paste, autofill):
<input type="search"
name="q"
hx-get="/api/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#results">
The search trigger handles the search input's clear button (X).
Self-Targeting with Polling
For elements that replace themselves (polling), use hx-target="this":
<div hx-get="/api/time"
hx-trigger="load, every 2s"
hx-target="this"
hx-swap="innerHTML">
Loading...
</div>
Row Updates with closest
For list items where each row has its own update button:
<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.
Event Attribute Syntax
The hx-on:: syntax uses double colons for htmx events:
<button hx-on::before-request="console.log('starting')">
<button hx-on:htmx:before-request="console.log('starting')">
Combining Multiple Triggers
Separate triggers with commas:
<div hx-get="/api/data"
hx-trigger="load, every 5s, click from:#refresh-btn">
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>
Additional Resources
For detailed reference, consult: