Overview
htmx is a small JavaScript library that allows you to access modern browser features directly from HTML, rather than using JavaScript. It extends HTML with attributes that enable AJAX requests, CSS transitions, WebSocket connections, and Server-Sent Events directly in your markup. The core philosophy is that HTML should be the primary medium for building web applications, returning to the original hypermedia-driven architecture of the web.
Created by Carson Gross, htmx is the successor to intercooler.js and weighs only about 14KB minified and gzipped. It works with any server-side language or framework since it simply exchanges HTML fragments over HTTP. This approach eliminates the need for complex JavaScript frameworks for many common web application patterns, reducing bundle sizes, simplifying architectures, and improving accessibility by default.
Installation
CDN
<!-- Latest version -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- With integrity check -->
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
npm
npm install htmx.org
# In your JavaScript entry
import 'htmx.org';
Minimal HTML Setup
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<button hx-get="/api/greeting" hx-target="#result">
Click Me
</button>
<div id="result"></div>
</body>
</html>
Core Attributes
AJAX Requests
| Attribute | Description | Example |
|---|
hx-get | Issue GET request | hx-get="/api/users" |
hx-post | Issue POST request | hx-post="/api/users" |
hx-put | Issue PUT request | hx-put="/api/users/1" |
hx-patch | Issue PATCH request | hx-patch="/api/users/1" |
hx-delete | Issue DELETE request | hx-delete="/api/users/1" |
Targeting and Swapping
| Attribute | Description | Example |
|---|
hx-target | Element to update with response | hx-target="#result" |
hx-swap | How to swap content | hx-swap="innerHTML" |
hx-select | Select part of response | hx-select="#content" |
hx-select-oob | Select out-of-band content | hx-select-oob="#sidebar" |
Swap Strategies
| Strategy | Description |
|---|
innerHTML | Replace inner HTML (default) |
outerHTML | Replace entire element |
beforebegin | Insert before element |
afterbegin | Insert at start of element |
beforeend | Insert at end of element |
afterend | Insert after element |
delete | Delete target element |
none | Don’t swap (just fire events) |
Common Patterns
Click to Load
<button hx-get="/api/users"
hx-target="#user-list"
hx-swap="innerHTML"
hx-indicator="#spinner">
Load Users
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>
<div id="user-list"></div>
Search with Debounce
<input type="search"
name="q"
hx-get="/api/search"
hx-target="#results"
hx-trigger="input changed delay:300ms, search"
hx-indicator="#search-spinner"
placeholder="Search...">
<div id="results"></div>
<table>
<tbody id="rows">
<tr>
<td>Row 1</td>
</tr>
<!-- Last row triggers loading more -->
<tr hx-get="/api/rows?page=2"
hx-target="#rows"
hx-swap="beforeend"
hx-trigger="revealed">
<td>Row 10</td>
</tr>
</tbody>
</table>
<form hx-post="/api/contacts"
hx-target="#contact-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()">
<input name="name" required>
<input name="email" type="email" required>
<button type="submit">Add Contact</button>
</form>
<div id="contact-list"></div>
Inline Editing
<!-- Display mode -->
<div id="user-1" hx-target="this" hx-swap="outerHTML">
<span>John Doe</span>
<button hx-get="/api/users/1/edit">Edit</button>
</div>
<!-- Server returns edit form -->
<form id="user-1" hx-put="/api/users/1" hx-target="this" hx-swap="outerHTML">
<input name="name" value="John Doe">
<button type="submit">Save</button>
<button hx-get="/api/users/1">Cancel</button>
</form>
Delete with Confirmation
<button hx-delete="/api/users/1"
hx-target="closest tr"
hx-swap="outerHTML swap:500ms"
hx-confirm="Are you sure you want to delete this user?">
Delete
</button>
Triggers
Trigger Modifiers
| Modifier | Description | Example |
|---|
changed | Only if value changed | hx-trigger="input changed" |
delay:Xs | Debounce by X seconds | hx-trigger="input delay:500ms" |
throttle:Xs | Throttle to every X seconds | hx-trigger="scroll throttle:200ms" |
once | Fire only once | hx-trigger="load once" |
from:selector | Listen on different element | hx-trigger="click from:body" |
target:selector | Filter by event target | hx-trigger="click target:.btn" |
consume | Prevent event propagation | hx-trigger="click consume" |
revealed | When element scrolls into view | hx-trigger="revealed" |
intersect | IntersectionObserver trigger | hx-trigger="intersect" |
every Xs | Poll every X seconds | hx-trigger="every 5s" |
load | On element load | hx-trigger="load" |
Examples
<!-- Multiple triggers -->
<input hx-get="/validate"
hx-trigger="change, keyup delay:200ms changed">
<!-- Keyboard shortcut -->
<div hx-get="/refresh"
hx-trigger="keyup[key=='r'] from:body">
<!-- Polling -->
<div hx-get="/api/notifications"
hx-trigger="every 30s"
hx-swap="innerHTML">
</div>
<!-- Load on page -->
<div hx-get="/api/stats"
hx-trigger="load"
hx-swap="innerHTML">
Loading stats...
</div>
Additional Attributes
| Attribute | Description | Example |
|---|
hx-include | Include additional inputs | hx-include="[name='token']" |
hx-vals | Add values to request | hx-vals='{"key": "value"}' |
hx-headers | Add HTTP headers | hx-headers='{"X-Token": "abc"}' |
hx-params | Filter parameters | hx-params="*" or hx-params="not secret" |
hx-indicator | Show during request | hx-indicator="#spinner" |
hx-disabled-elt | Disable during request | hx-disabled-elt="this" |
hx-confirm | Confirmation dialog | hx-confirm="Are you sure?" |
hx-push-url | Push URL to history | hx-push-url="true" |
hx-boost | Progressive enhancement | hx-boost="true" |
hx-preserve | Keep element across swaps | hx-preserve="true" |
hx-encoding | Request encoding | hx-encoding="multipart/form-data" |
Configuration
<meta name="htmx-config" content='{
"defaultSwapStyle": "outerHTML",
"defaultSwapDelay": 0,
"defaultSettleDelay": 20,
"includeIndicatorStyles": true,
"historyCacheSize": 10,
"useTemplateFragments": true,
"scrollBehavior": "smooth",
"getCacheBusterParam": false
}'>
CSS for Indicators
/* htmx adds/removes htmx-request class during requests */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Fade in swap */
.htmx-swapping {
opacity: 0;
transition: opacity 200ms ease-out;
}
Advanced Usage
Out-of-Band Swaps
<!-- Server response can update multiple elements -->
<!-- Main response updates target normally -->
<div id="message">Item created!</div>
<!-- OOB element updates sidebar regardless of target -->
<div id="sidebar" hx-swap-oob="true">
Updated sidebar content
</div>
Server-Sent Events
<div hx-ext="sse"
sse-connect="/api/events"
sse-swap="message">
Waiting for events...
</div>
<!-- Specific event types -->
<div hx-ext="sse" sse-connect="/api/events">
<div sse-swap="notification"></div>
<div sse-swap="update"></div>
</div>
WebSockets
<div hx-ext="ws" ws-connect="/ws/chat">
<div id="messages"></div>
<form ws-send>
<input name="message">
<button>Send</button>
</form>
</div>
| Header | Description |
|---|
HX-Redirect | Client-side redirect |
HX-Refresh | Full page refresh |
HX-Retarget | Change target element |
HX-Reswap | Change swap strategy |
HX-Trigger | Trigger client-side event |
HX-Push-Url | Push URL to history |
HX-Replace-Url | Replace URL in history |
# Python/Flask example
@app.route("/api/item", methods=["POST"])
def create_item():
# ... create item ...
response = make_response(render_template("item.html", item=item))
response.headers["HX-Trigger"] = "itemCreated"
return response
JavaScript API
// Listen to htmx events
document.body.addEventListener("htmx:afterSwap", function(evt) {
console.log("Swapped:", evt.detail.target);
});
document.body.addEventListener("htmx:beforeRequest", function(evt) {
// Modify request
evt.detail.xhr.setRequestHeader("X-Custom", "value");
});
// Programmatic requests
htmx.ajax("GET", "/api/data", { target: "#result", swap: "innerHTML" });
// Process new content
htmx.process(document.getElementById("new-content"));
Troubleshooting
| Problem | Solution |
|---|
| Request not firing | Check hx-trigger; default is click for buttons, change for inputs |
| Wrong element updating | Verify hx-target selector; use browser DevTools |
| Content not swapping | Check server returns HTML fragments, not full pages |
| CSRF token issues | Use hx-headers to include token, or hx-vals |
| Double requests | Check for duplicate triggers; use once modifier |
| History not working | Add hx-push-url="true" to navigation requests |
| Events not bubbling | Use from:body modifier on trigger |
| Extension not loading | Ensure extension script loaded after htmx; check hx-ext |