htmx
JavaScriptフレームワークを使わず、HTMLだけで動的なウェブUIを構築し、サーバー主導のインタラクティブなページ作成を支援するSkill。
📜 元の英語説明(参考)
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.
🇯🇵 日本人クリエイター向け解説
JavaScriptフレームワークを使わず、HTMLだけで動的なウェブUIを構築し、サーバー主導のインタラクティブなページ作成を支援するSkill。
※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。
下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o htmx.zip https://jpskill.com/download/6095.zip && unzip -o htmx.zip && rm htmx.zip
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/6095.zip -OutFile "$d\htmx.zip"; Expand-Archive "$d\htmx.zip" -DestinationPath $d -Force; ri "$d\htmx.zip"
完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。
💾 手動でダウンロードしたい(コマンドが難しい人向け)
- 1. 下の青いボタンを押して
htmx.zipをダウンロード - 2. ZIPファイルをダブルクリックで解凍 →
htmxフォルダができる - 3. そのフォルダを
C:\Users\あなたの名前\.claude\skills\(Win)または~/.claude/skills/(Mac)へ移動 - 4. Claude Code を再起動
⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。
🎯 このSkillでできること
下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。
📦 インストール方法 (3ステップ)
- 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
- 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
- 3. 展開してできたフォルダを、ホームフォルダの
.claude/skills/に置く- · macOS / Linux:
~/.claude/skills/ - · Windows:
%USERPROFILE%\.claude\skills\
- · macOS / Linux:
Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。
詳しい使い方ガイドを見る →- 最終更新
- 2026-05-17
- 取得日時
- 2026-05-17
- 同梱ファイル
- 1
📖 Skill本文(日本語訳)
※ 原文(英語/中国語)を Gemini で日本語化したものです。Claude 自身は原文を読みます。誤訳がある場合は原文をご確認ください。
[Skill 名] htmx
htmx
基本
htmxはHTML属性にスーパーパワーを与えます。これにより、あらゆる要素がHTTPリクエストを発行でき、サーバーはDOMにスワップされるHTMLフラグメントを返します。JSON API、クライアントサイドレンダリング、ビルドステップは不要です。サーバーが唯一の信頼できる情報源となります。
<!-- htmx を含める -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- どの要素でもリクエストを作成できます -->
<button hx-get="/api/users" hx-target="#user-list" hx-swap="innerHTML">
Load Users
</button>
<div id="user-list"></div>
コアリクエスト属性
<!-- 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 -->
<button hx-delete="/items/42" hx-confirm="Delete this item?">Delete</button>
スワップ戦略
hx-swapは、レスポンスのHTMLがコンテンツをどのように置き換えるかを制御します。
<!-- ターゲットの内部コンテンツを置き換える(デフォルト) -->
<div hx-get="/content" hx-swap="innerHTML">Load</div>
<!-- ターゲット要素全体を置き換える -->
<div hx-get="/content" hx-swap="outerHTML">Replace Me</div>
<!-- ターゲットの最初の子要素の前に挿入する -->
<div hx-get="/new-row" hx-swap="afterbegin">Prepend</div>
<!-- ターゲットの最後の子要素の後に挿入する -->
<div hx-get="/new-row" hx-swap="beforeend">Append</div>
<!-- ターゲット要素の前に挿入する -->
<div hx-get="/sibling" hx-swap="beforebegin">Before</div>
<!-- ターゲット要素の後に挿入する -->
<div hx-get="/sibling" hx-swap="afterend">After</div>
<!-- リクエスト後にターゲットを削除する -->
<button hx-delete="/items/42" hx-swap="delete" hx-target="closest tr">Remove</button>
<!-- スワップなし — リクエストは発行するがDOMは変更しない -->
<button hx-post="/track-click" hx-swap="none">Track</button>
<!-- スワップ修飾子 -->
<div hx-get="/data" hx-swap="innerHTML swap:300ms settle:500ms scroll:top show:top">
Smooth transitions
</div>
ターゲット
hx-targetは、レスポンスがどこに配置されるかを指定します。
<!-- CSSセレクター -->
<button hx-get="/users" hx-target="#results">Search</button>
<!-- 相対セレクター -->
<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 — 要素自体をスワップする -->
<div hx-get="/self-update" hx-target="this">Click to reload</div>
<!-- bodyをターゲットにする -->
<a hx-get="/page" hx-target="body">Full page swap</a>
トリガー
hx-triggerは、リクエストがいつ発行されるかを制御します。
<!-- デフォルト: ボタン/リンクはクリック、入力は変更、フォームは送信 -->
<input hx-get="/search" hx-trigger="keyup" hx-target="#results" name="q">
<!-- 修飾子 -->
<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: — 他の要素のイベントをリッスンする -->
<div hx-get="/updates" hx-trigger="click from:body">Refresh on any click</div>
<!-- 複数のトリガー -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms, search" name="q">
<!-- Intersection observer — 要素がビューポートに入ったときに発火する -->
<div hx-get="/lazy-content" hx-trigger="intersect once">Loading...</div>
<!-- Loadトリガー — ページロード時に発火する -->
<div hx-get="/dashboard-stats" hx-trigger="load">Loading stats...</div>
<!-- カスタムイベント -->
<div hx-get="/refresh" hx-trigger="refreshData from:body">Data</div>
インジケーター
リクエスト中に読み込み状態を表示します。
<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>
<!-- 親要素のインジケーター -->
<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>
<!-- リクエスト中にボタンを無効にする -->
<button hx-get="/data" hx-disabled-elt="this">Submit</button>
<!-- 複数の要素を無効にする -->
<button hx-post="/save" hx-disabled-elt="closest form">Save</button>
フォーム処理
<!-- フォームは自動的にすべての入力を含みます -->
<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>
<!-- トリガー要素の外部から入力を含める -->
<input id="search-input" name="q">
<button hx-get="/search" hx-include="#search-input" hx-target="#results">Search</button>
<!-- フォーム全体を含める -->
<button hx-post="/save" hx-include="closest form">Save</button>
<!-- フォームにない追加の値を加える -->
<button hx-post="/save" hx-vals='{"source": "web", "version": 2}'>Save</button>
<!-- JavaScriptによる動的な値 -->
<button hx-post="/save" hx-vals="js:{ts: Date.now()}">Save with Timestamp</button>
<!-- 送信するパラメーターを制御する -->
<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>
<!-- ファイルアップロード -->
<form hx-post="/upload" hx-encoding="multipart/form-data">
<input type="file" name="document">
<button>Upload</button>
</form>
アウトオブバンドスワップ
単一のレスポンスからページの複数の部分を更新します。
<!-- サーバーレスポンスにはアウトオブバンドスワップを含めることができます -->
<!-- メインのレスポンスは通常通りターゲットにスワップされます -->
<!-- Ele 📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開
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.