Learn-skills.dev htmx

htmx for building dynamic web UIs with HTML-over-the-wire. Use when user mentions "htmx", "hx-get", "hx-post", "hx-swap", "hx-trigger", "hypermedia", "HTML over the wire", "server-driven UI", "no JavaScript framework", "htmx boost", "progressive enhancement", "hyperscript", "alpine.js with htmx", or building interactive web pages without heavy JavaScript frameworks.

install
source · Clone the upstream repo
git clone https://github.com/NeverSight/learn-skills.dev
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/1mangesh1/dev-skills-collection/htmx" ~/.claude/skills/neversight-learn-skills-dev-htmx && rm -rf "$T"
manifest: data/skills-md/1mangesh1/dev-skills-collection/htmx/SKILL.md
source content

htmx

Fundamentals

htmx gives HTML attributes superpowers: any element can issue HTTP requests, and the server returns HTML fragments that get swapped into the DOM. No JSON APIs, no client-side rendering, no build step. The server is the single source of truth.

<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>

<!-- Any element can make requests -->
<button hx-get="/api/users" hx-target="#user-list" hx-swap="innerHTML">
  Load Users
</button>
<div id="user-list"></div>

Core Request Attributes

<!-- GET -->
<div hx-get="/items">Load Items</div>

<!-- POST -->
<button hx-post="/items" hx-vals='{"name": "New Item"}'>Create</button>

<!-- PUT -->
<button hx-put="/items/42" hx-vals='{"name": "Updated"}'>Update</button>

<!-- PATCH -->
<button hx-patch="/items/42" hx-vals='{"status": "done"}'>Mark Done</button>

<!-- DELETE with confirmation -->
<button hx-delete="/items/42" hx-confirm="Delete this item?">Delete</button>

Swap Strategies

hx-swap
controls how the response HTML replaces content.

<!-- Replace inner content of target (default) -->
<div hx-get="/content" hx-swap="innerHTML">Load</div>

<!-- Replace entire target element -->
<div hx-get="/content" hx-swap="outerHTML">Replace Me</div>

<!-- Insert before target's first child -->
<div hx-get="/new-row" hx-swap="afterbegin">Prepend</div>

<!-- Insert after target's last child -->
<div hx-get="/new-row" hx-swap="beforeend">Append</div>

<!-- Insert before the target element -->
<div hx-get="/sibling" hx-swap="beforebegin">Before</div>

<!-- Insert after the target element -->
<div hx-get="/sibling" hx-swap="afterend">After</div>

<!-- Delete target after request -->
<button hx-delete="/items/42" hx-swap="delete" hx-target="closest tr">Remove</button>

<!-- No swap — fire request but keep DOM unchanged -->
<button hx-post="/track-click" hx-swap="none">Track</button>

<!-- Swap modifiers -->
<div hx-get="/data" hx-swap="innerHTML swap:300ms settle:500ms scroll:top show:top">
  Smooth transitions
</div>

Targets

hx-target
specifies where the response gets placed.

<!-- CSS selector -->
<button hx-get="/users" hx-target="#results">Search</button>

<!-- Relative selectors -->
<button hx-get="/edit" hx-target="closest .card">Edit Card</button>
<button hx-get="/detail" hx-target="find .content">Show Detail</button>
<button hx-get="/next" hx-target="next .panel">Next Panel</button>
<button hx-get="/prev" hx-target="previous .panel">Prev Panel</button>

<!-- this — swap the element itself -->
<div hx-get="/self-update" hx-target="this">Click to reload</div>

<!-- Target the body -->
<a hx-get="/page" hx-target="body">Full page swap</a>

Triggers

hx-trigger
controls when the request fires.

<!-- Default: click for buttons/links, change for inputs, submit for forms -->
<input hx-get="/search" hx-trigger="keyup" hx-target="#results" name="q">

<!-- Modifiers -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms" name="q">
<input hx-get="/validate" hx-trigger="keyup throttle:500ms" name="email">
<div hx-get="/news" hx-trigger="every 30s">Live feed</div>

<!-- from: — listen to events on other elements -->
<div hx-get="/updates" hx-trigger="click from:body">Refresh on any click</div>

<!-- Multiple triggers -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms, search" name="q">

<!-- Intersection observer — fires when element enters viewport -->
<div hx-get="/lazy-content" hx-trigger="intersect once">Loading...</div>

<!-- Load trigger — fires on page load -->
<div hx-get="/dashboard-stats" hx-trigger="load">Loading stats...</div>

<!-- Custom events -->
<div hx-get="/refresh" hx-trigger="refreshData from:body">Data</div>

Indicators

Show loading state during requests.

<button hx-get="/slow-data" hx-indicator="#spinner">
  Load Data
  <span id="spinner" class="htmx-indicator">Loading...</span>
</button>

<style>
  .htmx-indicator { display: none; }
  .htmx-request .htmx-indicator { display: inline; }
  .htmx-request.htmx-indicator { display: inline; }
</style>

<!-- Indicator on parent -->
<div hx-indicator="closest .card">
  <button hx-get="/data">Load</button>
  <div class="htmx-indicator">
    <svg class="animate-spin h-5 w-5">...</svg>
  </div>
</div>

<!-- Disable button during request -->
<button hx-get="/data" hx-disabled-elt="this">Submit</button>
<!-- Disable multiple elements -->
<button hx-post="/save" hx-disabled-elt="closest form">Save</button>

Form Handling

<!-- Forms automatically include all inputs -->
<form hx-post="/contacts" hx-target="#contact-list" hx-swap="beforeend">
  <input name="name" required>
  <input name="email" type="email" required>
  <button type="submit">Add Contact</button>
</form>

<!-- Include inputs from outside the triggering element -->
<input id="search-input" name="q">
<button hx-get="/search" hx-include="#search-input" hx-target="#results">Search</button>

<!-- Include entire form -->
<button hx-post="/save" hx-include="closest form">Save</button>

<!-- Add extra values not in the form -->
<button hx-post="/save" hx-vals='{"source": "web", "version": 2}'>Save</button>

<!-- Dynamic values with JavaScript -->
<button hx-post="/save" hx-vals="js:{ts: Date.now()}">Save with Timestamp</button>

<!-- Control which params are sent -->
<form hx-post="/update" hx-params="*">Send all</form>
<form hx-post="/update" hx-params="none">Send none</form>
<form hx-post="/update" hx-params="name,email">Send specific</form>
<form hx-post="/update" hx-params="not password">Exclude specific</form>

<!-- File upload -->
<form hx-post="/upload" hx-encoding="multipart/form-data">
  <input type="file" name="document">
  <button>Upload</button>
</form>

Out-of-Band Swaps

Update multiple parts of the page from a single response.

<!-- Server response can include out-of-band swaps -->
<!-- Main response gets swapped into target as usual -->
<!-- Elements with hx-swap-oob get swapped into matching IDs -->

Server returns:

<div id="main-content">
  <!-- This goes to the normal target -->
  <p>Item saved successfully.</p>
</div>
<div id="item-count" hx-swap-oob="true">Total: 43 items</div>
<div id="notification" hx-swap-oob="innerHTML">Saved at 2:30 PM</div>
<tr id="row-42" hx-swap-oob="outerHTML">
  <td>Updated Row</td>
</tr>

Headers

Request Headers (sent by htmx)

HX-Request: true              — always sent, use to detect htmx requests
HX-Target: element-id         — id of the target element
HX-Trigger: element-id        — id of the triggered element
HX-Trigger-Name: name-attr    — name attribute of the trigger
HX-Current-URL: url            — current URL of the browser
HX-Prompt: value              — user response from hx-prompt
HX-Boosted: true              — if request is via hx-boost

Response Headers (sent by server)

HX-Redirect: /new-url         — client-side redirect
HX-Refresh: true              — full page refresh
HX-Retarget: #new-target      — change the target element
HX-Reswap: outerHTML          — change the swap strategy
HX-Trigger: eventName         — trigger client-side event after settle
HX-Trigger: {"showToast": {"message": "Saved!"}}  — trigger with detail
HX-Push-Url: /new-url         — push URL to browser history

Backend Integration

Express (Node.js)

const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));

app.get('/contacts', (req, res) => {
  const isHtmx = req.headers['hx-request'];
  const contacts = getContacts(req.query.q);
  const html = contacts.map(c =>
    `<tr><td>${c.name}</td><td>${c.email}</td></tr>`
  ).join('');
  if (isHtmx) return res.send(html);           // return fragment
  res.render('contacts', { contacts });          // return full page
});

app.delete('/contacts/:id', (req, res) => {
  deleteContact(req.params.id);
  res.set('HX-Trigger', 'contactsChanged');
  res.send('');                                  // empty response with delete swap
});

Flask (Python)

from flask import Flask, request, render_template

app = Flask(__name__)

@app.route('/search')
def search():
    q = request.args.get('q', '')
    results = search_contacts(q)
    if request.headers.get('HX-Request'):
        return render_template('partials/contact_rows.html', contacts=results)
    return render_template('search.html', contacts=results)

@app.route('/contacts', methods=['POST'])
def create_contact():
    contact = create(request.form)
    resp = make_response(render_template('partials/contact_row.html', contact=contact))
    resp.headers['HX-Trigger'] = 'contactsChanged'
    return resp

Django

from django.http import HttpResponse
from django.template.loader import render_to_string

def contact_list(request):
    contacts = Contact.objects.filter(name__icontains=request.GET.get('q', ''))
    if request.headers.get('HX-Request'):
        html = render_to_string('partials/rows.html', {'contacts': contacts})
        return HttpResponse(html)
    return render(request, 'contacts.html', {'contacts': contacts})

Go

func handleSearch(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query().Get("q")
    results := searchContacts(q)
    if r.Header.Get("HX-Request") != "" {
        tmpl.ExecuteTemplate(w, "contact-rows", results)
        return
    }
    tmpl.ExecuteTemplate(w, "full-page", results)
}

Common Patterns

Active Search

<input type="search" name="q"
  hx-get="/search"
  hx-trigger="input changed delay:300ms, search"
  hx-target="#search-results"
  hx-indicator="#search-spinner"
  placeholder="Search contacts...">
<span id="search-spinner" class="htmx-indicator">Searching...</span>
<table><tbody id="search-results"></tbody></table>

Infinite Scroll

<table><tbody id="results">
  <!-- rows here -->
  <tr hx-get="/contacts?page=2"
      hx-trigger="revealed"
      hx-swap="afterend"
      hx-select="tbody > tr">
    <td>Loading more...</td>
  </tr>
</tbody></table>

Click to Edit

<!-- Display mode -->
<div hx-get="/contacts/42/edit" hx-trigger="click" hx-swap="outerHTML">
  <p>John Doe — john@example.com</p>
</div>

<!-- Server returns edit form -->
<form hx-put="/contacts/42" hx-swap="outerHTML">
  <input name="name" value="John Doe">
  <input name="email" value="john@example.com">
  <button>Save</button>
  <button hx-get="/contacts/42" hx-swap="outerHTML">Cancel</button>
</form>

Bulk Update

<form hx-put="/contacts/bulk" hx-target="#table-body" hx-swap="innerHTML">
  <input type="checkbox" id="select-all"
    onclick="document.querySelectorAll('.row-check').forEach(c => c.checked = this.checked)">
  <table><tbody id="table-body">
    <tr>
      <td><input type="checkbox" class="row-check" name="ids" value="1"></td>
      <td>Contact 1</td>
    </tr>
  </tbody></table>
  <button>Activate Selected</button>
</form>

Lazy Loading

<div hx-get="/dashboard/chart" hx-trigger="load" hx-swap="outerHTML">
  <div class="skeleton-loader" style="height: 300px;"></div>
</div>

Delete Row with Animation

<tr>
  <td>Item Name</td>
  <td>
    <button hx-delete="/items/42"
            hx-target="closest tr"
            hx-swap="outerHTML swap:500ms"
            hx-confirm="Delete this item?">
      Delete
    </button>
  </td>
</tr>

<style>
  tr.htmx-swapping { opacity: 0; transition: opacity 500ms ease-out; }
</style>

Boosting

hx-boost
converts standard links and forms into AJAX requests with history support. Drop-in progressive enhancement.

<!-- Boost all links and forms in this container -->
<div hx-boost="true">
  <a href="/about">About</a>              <!-- becomes hx-get="/about" -->
  <a href="/contact">Contact</a>

  <form action="/search" method="get">     <!-- becomes hx-get="/search" -->
    <input name="q">
    <button>Search</button>
  </form>
</div>

<!-- Boost the entire body for SPA-like navigation -->
<body hx-boost="true">
  <!-- All navigation is now AJAX -->
</body>

<!-- Exclude specific links -->
<a href="/download.pdf" hx-boost="false">Download PDF</a>

<!-- Push URL to history (default with boost) -->
<a hx-get="/page" hx-push-url="true">Navigate</a>
<a hx-get="/modal" hx-push-url="false">Open Modal</a>

WebSocket and SSE Extensions

<!-- Load extension -->
<script src="https://unpkg.com/htmx-ext-ws@2.0.0/ws.js"></script>

<!-- WebSocket -->
<div hx-ext="ws" ws-connect="/ws/chat">
  <div id="chat-messages"></div>
  <form ws-send>
    <input name="message">
    <button>Send</button>
  </form>
</div>

<!-- Server-Sent Events -->
<script src="https://unpkg.com/htmx-ext-sse@2.0.0/sse.js"></script>
<div hx-ext="sse" sse-connect="/events">
  <div sse-swap="notification">Waiting for notifications...</div>
  <div sse-swap="status">Status: unknown</div>
</div>

Alpine.js + htmx

Alpine handles client-side state; htmx handles server communication.

<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>

<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>

  <div x-show="open" x-transition>
    <div hx-get="/panel-content" hx-trigger="intersect once" hx-swap="innerHTML">
      Loading...
    </div>
  </div>
</div>

<!-- Alpine reacts to htmx events -->
<div x-data="{ saving: false }"
     @htmx:before-request.window="saving = true"
     @htmx:after-request.window="saving = false">
  <span x-show="saving">Saving...</span>
  <form hx-post="/save">
    <input name="data">
    <button>Save</button>
  </form>
</div>

CSS Transitions

htmx adds classes during the swap lifecycle for animation hooks.

/* Element being removed */
.htmx-swapping { opacity: 0; transition: opacity 0.5s ease-out; }

/* New content settling in */
.htmx-added { opacity: 0; }
.htmx-settling { opacity: 1; transition: opacity 0.3s ease-in; }

/* During request */
.htmx-request { opacity: 0.5; }
<!-- Use swap/settle timing to match CSS transitions -->
<div hx-get="/new-content" hx-swap="innerHTML swap:500ms settle:300ms">
  Animated swap
</div>

<!-- View Transitions API (modern browsers) -->
<div hx-get="/page" hx-swap="innerHTML transition:true">Navigate</div>

Validation Patterns

<!-- Inline field validation -->
<input name="email" type="email"
  hx-post="/validate/email"
  hx-trigger="blur changed"
  hx-target="next .error"
  hx-swap="innerHTML">
<span class="error"></span>

<!-- Server returns validation HTML -->
<!-- Success: empty string or checkmark -->
<!-- Failure: <span class="text-red-500">Email already taken</span> -->

<!-- Form-level validation with error summary -->
<form hx-post="/register" hx-target="#form-errors" hx-swap="innerHTML">
  <div id="form-errors"></div>
  <input name="username" required>
  <input name="email" type="email" required>
  <button>Register</button>
</form>

<!-- Prevent request if client validation fails -->
<form hx-post="/save"
      hx-trigger="submit"
      hx-on::before-request="if(!this.checkValidity()){event.preventDefault();this.reportValidity()}">
  <input name="name" required>
  <button>Save</button>
</form>

htmx Events

<!-- Listen to htmx events -->
<div hx-get="/data" hx-trigger="load"
     hx-on::after-settle="console.log('Content loaded')">
  Loading...
</div>

<!-- JavaScript event listeners -->
<script>
  document.body.addEventListener('htmx:beforeRequest', (e) => {
    console.log('Request starting:', e.detail.pathInfo);
  });
  document.body.addEventListener('htmx:afterSwap', (e) => {
    console.log('Content swapped into:', e.detail.target);
  });
  document.body.addEventListener('htmx:responseError', (e) => {
    alert('Request failed: ' + e.detail.xhr.status);
  });

  // Respond to server-triggered events via HX-Trigger header
  document.body.addEventListener('showToast', (e) => {
    showNotification(e.detail.message);
  });
</script>

htmx vs React/Vue Trade-offs

Choose htmx when:

  • Server-rendered apps (Django, Rails, Laravel, Go templates)
  • CRUD-heavy applications with straightforward interactions
  • Enhancing existing multi-page apps without a rewrite
  • Team knows backend well but not frontend frameworks
  • SEO is critical and SSR complexity is unwanted
  • Minimal client-side state management needed

Choose React/Vue/Svelte when:

  • Complex client-side state (real-time collaboration, drag-and-drop)
  • Rich interactive UIs (spreadsheets, design tools, IDEs)
  • Offline-first or PWA requirements
  • Large ecosystem of UI component libraries needed
  • Team already invested in a JS framework
  • Need for native mobile apps via React Native or similar

Hybrid approach: Use htmx for most pages, embed React/Vue components for complex widgets. htmx handles navigation and data mutations; JS frameworks handle rich interactivity.