HTMX Integration
UX is framework-agnostic. Every component is pure CSS — you control state with data-state attributes, which HTMX can set via hx-on events. Here are real-world patterns.
Setup
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/ERPlora/ux@3/dist/ux.min.css">
<script src="https://unpkg.com/htmx.org@2"></script> Modal — Load Content & Open
User Details
<!-- Trigger: fetch content, then open modal -->
<button class="btn color-primary"
hx-get="/api/users/42"
hx-target="#modal-body"
hx-on::after-swap="document.getElementById('user-modal').dataset.state='open'">
View User
</button>
<!-- Modal (closed by default) -->
<div id="user-modal" class="modal-backdrop" data-state="closed">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">User Details</h3>
<button class="modal-close"
hx-on:click="this.closest('[data-state]').dataset.state='closed'">×</button>
</div>
<div id="modal-body" class="modal-body">
<!-- Content loaded by HTMX -->
</div>
</div>
</div> Requires: ux.min.css
<!-- Trigger -->
<button class="inline-flex items-center justify-center h-11 px-4 bg-primary text-primary-content rounded-lg font-semibold hover:brightness-95 active:scale-[0.97] transition-all"
hx-get="/api/users/42"
hx-target="#modal-body"
hx-on::after-swap="document.getElementById('user-modal').dataset.state='open'">
View User
</button>
<!-- Modal backdrop -->
<div id="user-modal" class="fixed inset-0 z-400 flex items-center justify-center bg-black/50 opacity-0 pointer-events-none transition-opacity duration-200" data-state="closed">
<div class="bg-base-100 rounded-box shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between p-4 border-b border-base-300">
<h3 class="font-semibold">User Details</h3>
<button class="text-base-content/50 hover:text-base-content text-xl leading-none"
hx-on:click="this.closest('[data-state]').dataset.state='closed'">×</button>
</div>
<div id="modal-body" class="p-4">
<!-- Content loaded by HTMX -->
</div>
</div>
</div> Requires: tw.min.css
// The modal opens via hx-on::after-swap (no extra JS needed).
// For closing on backdrop click, add:
document.getElementById('user-modal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop')) {
e.target.dataset.state = 'closed';
}
});
// Close on Escape key:
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('[data-state="open"]').forEach(el => {
el.dataset.state = 'closed';
});
}
}); Form — Submit with Loading State
<form hx-post="/api/users"
hx-target="#form-result"
hx-swap="innerHTML"
hx-indicator="#submit-btn"
hx-on::before-request="this.querySelector('.btn').classList.add('btn-loading')"
hx-on::after-request="this.querySelector('.btn').classList.remove('btn-loading')">
<div class="form-group">
<label class="label">Name</label>
<input class="input" type="text" name="name" required placeholder="John Doe">
</div>
<div class="form-group">
<label class="label">Email</label>
<input class="input" type="email" name="email" required placeholder="john@example.com">
</div>
<button id="submit-btn" class="btn color-primary w-full" type="submit">
Save User
</button>
<div id="form-result" class="mt-3"></div>
</form> Requires: ux.min.css
<form hx-post="/api/users"
hx-target="#form-result"
hx-swap="innerHTML"
hx-on::before-request="this.querySelector('button[type=submit]').disabled=true"
hx-on::after-request="this.querySelector('button[type=submit]').disabled=false">
<div class="mb-4">
<label class="block text-sm font-medium mb-1.5">Name</label>
<input class="w-full h-11 px-3 rounded-field border border-base-300 bg-base-100 text-base-content outline-none focus:border-primary transition-colors" type="text" name="name" required placeholder="John Doe">
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1.5">Email</label>
<input class="w-full h-11 px-3 rounded-field border border-base-300 bg-base-100 text-base-content outline-none focus:border-primary transition-colors" type="email" name="email" required placeholder="john@example.com">
</div>
<button class="w-full inline-flex items-center justify-center h-11 px-4 bg-primary text-primary-content rounded-lg font-semibold hover:brightness-95 active:scale-[0.97] transition-all disabled:opacity-50" type="submit">
Save User
</button>
<div id="form-result" class="mt-3"></div>
</form> Requires: tw.min.css
// HTMX handles everything via hx-on:: events.
// The server should return HTML for #form-result, e.g.:
// <div class="alert color-success">User saved!</div>
// or validation errors:
// <div class="alert color-danger">Email already exists</div> Searchbar — Live Search with Debounce
<div class="searchbar">
<svg class="searchbar-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input class="searchbar-input"
type="search"
name="q"
placeholder="Search products..."
hx-get="/api/products/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-spinner">
<div id="search-spinner" class="spinner spinner-sm htmx-indicator"></div>
</div>
<div id="search-results" class="mt-3">
<div class="list">
<div class="list-item">
<span class="list-item-title">MacBook Pro 16"</span>
<span class="badge color-success">In Stock</span>
</div>
<div class="list-item">
<span class="list-item-title">iPad Air</span>
<span class="badge color-warning">Low Stock</span>
</div>
<div class="list-item">
<span class="list-item-title">AirPods Pro</span>
<span class="badge color-success">In Stock</span>
</div>
</div>
</div> Requires: ux.min.css
<div class="relative flex items-center">
<svg class="absolute left-3 w-5 h-5 text-base-content/40 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input class="w-full h-11 pl-10 pr-10 rounded-full border border-base-300 bg-base-100 text-base-content outline-none focus:border-primary transition-colors"
type="search"
name="q"
placeholder="Search products..."
hx-get="/api/products/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-spinner">
<div id="search-spinner" class="absolute right-3 w-5 h-5 rounded-full border-2 border-base-300 border-t-primary animate-spin htmx-indicator"></div>
</div>
<div id="search-results" class="mt-3 border border-base-300 rounded-box overflow-hidden divide-y divide-base-300">
<div class="flex items-center justify-between px-4 py-3 hover:bg-base-200 transition-colors">
<span class="font-medium text-sm">MacBook Pro 16"</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-success/10 text-success font-medium">In Stock</span>
</div>
<div class="flex items-center justify-between px-4 py-3 hover:bg-base-200 transition-colors">
<span class="font-medium text-sm">iPad Air</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-warning/10 text-warning font-medium">Low Stock</span>
</div>
</div> Requires: tw.min.css
// HTMX handles debounced search automatically.
// Server returns HTML list items:
// GET /api/products/search?q=mac → returns <div class="list-item">...</div>
//
// The htmx-indicator class shows/hides the spinner.
// Built-in CSS: .htmx-indicator { opacity: 0; transition: opacity 200ms; }
// .htmx-request .htmx-indicator { opacity: 1; } Tabs — Lazy Load Content per Tab
Select a tab to load its content...
<div class="tabs">
<div class="tabs-list">
<button class="tab tab-active"
hx-get="/api/tabs/overview"
hx-target="#tab-content"
hx-trigger="click"
hx-swap="innerHTML">Overview</button>
<button class="tab"
hx-get="/api/tabs/orders"
hx-target="#tab-content"
hx-trigger="click"
hx-swap="innerHTML">Orders</button>
<button class="tab"
hx-get="/api/tabs/analytics"
hx-target="#tab-content"
hx-trigger="click"
hx-swap="innerHTML">Analytics</button>
</div>
</div>
<div id="tab-content" class="p-4">
<p class="text-base-content/60">Select a tab to load its content...</p>
</div> Requires: ux.min.css
<div class="flex border-b border-base-300">
<button class="px-4 py-2.5 text-sm font-medium border-b-2 border-primary text-primary"
hx-get="/api/tabs/overview"
hx-target="#tab-content"
hx-trigger="click"
hx-swap="innerHTML">Overview</button>
<button class="px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-base-content/60 hover:text-base-content"
hx-get="/api/tabs/orders"
hx-target="#tab-content"
hx-trigger="click"
hx-swap="innerHTML">Orders</button>
<button class="px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-base-content/60 hover:text-base-content"
hx-get="/api/tabs/analytics"
hx-target="#tab-content"
hx-trigger="click"
hx-swap="innerHTML">Analytics</button>
</div>
<div id="tab-content" class="p-4">
<p class="text-base-content/60">Select a tab to load its content...</p>
</div> Requires: tw.min.css
// Active tab styling via HTMX events:
document.querySelectorAll('.tab, [hx-get*="tabs"]').forEach(tab => {
tab.addEventListener('htmx:beforeRequest', () => {
// Remove active from siblings
tab.parentElement.querySelectorAll('.tab-active, .border-primary')
.forEach(t => t.classList.remove('tab-active'));
tab.classList.add('tab-active');
});
}); DataTable — Server-Side Pagination & Sort
| Name ↕ | Email ↕ | Role |
|---|---|---|
| Ana García | ana@example.com | Admin |
| Carlos López | carlos@example.com | User |
| María Ruiz | maria@example.com | User |
<div id="datatable-container"
hx-get="/api/users?page=1"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<table class="table">
<thead>
<tr>
<th hx-get="/api/users?sort=name&page=1"
hx-target="#datatable-container"
class="cursor-pointer hover:text-primary">
Name ↕
</th>
<th hx-get="/api/users?sort=email&page=1"
hx-target="#datatable-container"
class="cursor-pointer hover:text-primary">
Email ↕
</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr><td>Ana García</td><td>ana@example.com</td><td><span class="badge color-primary badge-soft">Admin</span></td></tr>
<tr><td>Carlos López</td><td>carlos@example.com</td><td><span class="badge badge-soft">User</span></td></tr>
<tr><td>María Ruiz</td><td>maria@example.com</td><td><span class="badge badge-soft">User</span></td></tr>
</tbody>
</table>
<!-- Pagination -->
<div class="pagination mt-4">
<button class="pagination-btn" disabled>Prev</button>
<button class="pagination-btn pagination-active">1</button>
<button class="pagination-btn"
hx-get="/api/users?page=2"
hx-target="#datatable-container">2</button>
<button class="pagination-btn"
hx-get="/api/users?page=3"
hx-target="#datatable-container">3</button>
<button class="pagination-btn"
hx-get="/api/users?page=2"
hx-target="#datatable-container">Next</button>
</div>
</div> Requires: ux.min.css
<div id="datatable-container"
hx-get="/api/users?page=1"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="overflow-x-auto border border-base-300 rounded-box">
<table class="w-full text-sm">
<thead class="bg-base-200 text-base-content/70">
<tr>
<th class="text-left px-4 py-3 font-medium cursor-pointer hover:text-primary"
hx-get="/api/users?sort=name&page=1"
hx-target="#datatable-container">Name ↕</th>
<th class="text-left px-4 py-3 font-medium cursor-pointer hover:text-primary"
hx-get="/api/users?sort=email&page=1"
hx-target="#datatable-container">Email ↕</th>
<th class="text-left px-4 py-3 font-medium">Role</th>
</tr>
</thead>
<tbody class="divide-y divide-base-300">
<tr class="hover:bg-base-200/50"><td class="px-4 py-3">Ana García</td><td class="px-4 py-3">ana@example.com</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">Admin</span></td></tr>
<tr class="hover:bg-base-200/50"><td class="px-4 py-3">Carlos López</td><td class="px-4 py-3">carlos@example.com</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded-full bg-base-200 font-medium">User</span></td></tr>
</tbody>
</table>
</div>
<div class="flex gap-1 mt-4">
<button class="h-9 px-3 rounded-lg text-sm border border-base-300 opacity-50" disabled>Prev</button>
<button class="h-9 px-3 rounded-lg text-sm bg-primary text-primary-content font-medium">1</button>
<button class="h-9 px-3 rounded-lg text-sm border border-base-300 hover:bg-base-200" hx-get="/api/users?page=2" hx-target="#datatable-container">2</button>
<button class="h-9 px-3 rounded-lg text-sm border border-base-300 hover:bg-base-200" hx-get="/api/users?page=2" hx-target="#datatable-container">Next</button>
</div>
</div> Requires: tw.min.css
// Server returns full table HTML for each page/sort request.
// No client-side JS needed — HTMX swaps the entire container.
//
// Server endpoint example (Express):
// app.get('/api/users', (req, res) => {
// const { page = 1, sort = 'name' } = req.query;
// const users = db.getUsers({ page, sort, limit: 10 });
// res.send(renderTableHTML(users, page, sort));
// }); Infinite Scroll — Load More on Reveal
<div class="list" id="feed">
<div class="list-item">
<div class="avatar avatar-sm"><span>AG</span></div>
<div class="list-item-content">
<span class="list-item-title">Ana García</span>
<span class="list-item-description">Updated the project settings</span>
</div>
<span class="text-xs text-base-content/50">2m ago</span>
</div>
<div class="list-item">
<div class="avatar avatar-sm"><span>CL</span></div>
<div class="list-item-content">
<span class="list-item-title">Carlos López</span>
<span class="list-item-description">Pushed 3 commits to main</span>
</div>
<span class="text-xs text-base-content/50">5m ago</span>
</div>
<div class="list-item">
<div class="avatar avatar-sm"><span>MR</span></div>
<div class="list-item-content">
<span class="list-item-title">María Ruiz</span>
<span class="list-item-description">Created a new branch feature/auth</span>
</div>
<span class="text-xs text-base-content/50">12m ago</span>
</div>
<!-- Sentinel: triggers when scrolled into view -->
<div hx-get="/api/feed?page=2"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-indicator="#load-spinner">
<div id="load-spinner" class="flex justify-center py-4 htmx-indicator">
<div class="spinner spinner-sm"></div>
</div>
</div>
</div> Requires: ux.min.css
<div class="border border-base-300 rounded-box divide-y divide-base-300" id="feed">
<div class="flex items-center gap-3 px-4 py-3">
<div class="w-8 h-8 rounded-full bg-primary/10 text-primary text-xs font-medium flex items-center justify-center shrink-0">AG</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">Ana García</p>
<p class="text-xs text-base-content/60 truncate">Updated the project settings</p>
</div>
<span class="text-xs text-base-content/50 shrink-0">2m ago</span>
</div>
<div class="flex items-center gap-3 px-4 py-3">
<div class="w-8 h-8 rounded-full bg-success/10 text-success text-xs font-medium flex items-center justify-center shrink-0">CL</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">Carlos López</p>
<p class="text-xs text-base-content/60 truncate">Pushed 3 commits to main</p>
</div>
<span class="text-xs text-base-content/50 shrink-0">5m ago</span>
</div>
<!-- Sentinel -->
<div hx-get="/api/feed?page=2"
hx-trigger="revealed"
hx-swap="outerHTML">
<div class="flex justify-center py-4 htmx-indicator">
<div class="w-5 h-5 rounded-full border-2 border-base-300 border-t-primary animate-spin"></div>
</div>
</div>
</div> Requires: tw.min.css
// The sentinel div at the bottom triggers when visible.
// Server returns more list items + a new sentinel for the next page:
//
// GET /api/feed?page=2 returns:
// <div class="list-item">...item 4...</div>
// <div class="list-item">...item 5...</div>
// <div hx-get="/api/feed?page=3" hx-trigger="revealed" hx-swap="outerHTML">
// <div class="htmx-indicator">...</div>
// </div>
//
// On last page, omit the sentinel to stop loading. Toast — Server-Sent Notifications
<!-- Toast container (always visible, toasts append here) -->
<div id="toast-container" class="toast-container toast-top-right"></div>
<!-- Delete button: server returns toast HTML -->
<button class="btn color-danger btn-outline"
hx-delete="/api/users/42"
hx-target="#toast-container"
hx-swap="beforeend"
hx-confirm="Delete this user?">
Delete User
</button>
<!-- Server response (appended to container): -->
<!--
<div class="toast color-success" role="alert"
hx-on::load="setTimeout(() => this.remove(), 3000)">
<svg class="toast-icon">...</svg>
<div class="toast-content">
<p class="toast-title">Deleted</p>
<p class="toast-description">User removed successfully</p>
</div>
<button class="toast-close" hx-on:click="this.parentElement.remove()">×</button>
</div>
--> Requires: ux.min.css
<!-- Toast container -->
<div id="toast-container" class="fixed top-4 right-4 z-800 flex flex-col gap-2 w-80"></div>
<!-- Trigger -->
<button class="inline-flex items-center justify-center h-11 px-4 rounded-lg font-semibold border-2 border-error text-error hover:bg-error hover:text-error-content transition-all"
hx-delete="/api/users/42"
hx-target="#toast-container"
hx-swap="beforeend"
hx-confirm="Delete this user?">
Delete User
</button>
<!-- Server returns this HTML (auto-removes after 3s): -->
<!--
<div class="flex items-start gap-3 p-4 bg-success text-success-content rounded-box shadow-lg"
role="alert"
hx-on::load="setTimeout(() => this.remove(), 3000)">
<p class="text-sm font-medium flex-1">User removed successfully</p>
<button class="opacity-70 hover:opacity-100" hx-on:click="this.parentElement.remove()">×</button>
</div>
--> Requires: tw.min.css
// The toast auto-dismisses using hx-on::load on the server response.
// hx-on::load fires when the element is added to the DOM.
//
// Server returns HTML fragment with self-dismissal:
// <div class="toast color-success" role="alert"
// hx-on::load="setTimeout(() => this.remove(), 3000)">
// <div class="toast-content">
// <p class="toast-title">Deleted</p>
// <p class="toast-description">User removed</p>
// </div>
// </div> Sheet — Slide-Up with Dynamic Content
Order Details
<!-- Trigger: load order details into sheet -->
<button class="btn color-primary btn-outline"
hx-get="/api/orders/1001"
hx-target="#sheet-body"
hx-on::after-swap="document.getElementById('order-sheet').dataset.state='open'">
View Order #1001
</button>
<!-- Sheet -->
<div id="order-sheet" class="sheet-backdrop" data-state="closed"
hx-on:click="if(event.target===this) this.dataset.state='closed'">
<div class="sheet">
<div class="sheet-handle"></div>
<div class="sheet-header">
<h3 class="sheet-title">Order Details</h3>
<button class="sheet-close"
hx-on:click="this.closest('[data-state]').dataset.state='closed'">×</button>
</div>
<div id="sheet-body" class="sheet-body">
<!-- Loaded by HTMX -->
<div class="skeleton skeleton-text" style="--skeleton-lines: 5;"></div>
</div>
</div>
</div> Requires: ux.min.css
<button class="inline-flex items-center justify-center h-11 px-4 rounded-lg font-semibold border-2 border-primary text-primary hover:bg-primary hover:text-primary-content transition-all"
hx-get="/api/orders/1001"
hx-target="#sheet-body"
hx-on::after-swap="document.getElementById('order-sheet').dataset.state='open'">
View Order #1001
</button>
<div id="order-sheet" class="fixed inset-0 z-400 bg-black/50 opacity-0 pointer-events-none transition-opacity duration-200" data-state="closed"
hx-on:click="if(event.target===this) this.dataset.state='closed'">
<div class="absolute bottom-0 left-0 right-0 bg-base-100 rounded-t-2xl shadow-xl max-h-[80vh] flex flex-col transform translate-y-full transition-transform duration-300">
<div class="flex justify-center pt-2 pb-1"><div class="w-8 h-1 rounded-full bg-base-300"></div></div>
<div class="flex items-center justify-between px-4 py-2 border-b border-base-300">
<h3 class="font-semibold">Order Details</h3>
<button class="text-xl text-base-content/50 hover:text-base-content"
hx-on:click="this.closest('[data-state]').dataset.state='closed'">×</button>
</div>
<div id="sheet-body" class="p-4 overflow-y-auto flex-1">
<div class="space-y-3 animate-pulse">
<div class="h-4 bg-base-200 rounded w-3/4"></div>
<div class="h-4 bg-base-200 rounded w-1/2"></div>
<div class="h-4 bg-base-200 rounded w-5/6"></div>
</div>
</div>
</div>
</div> Requires: tw.min.css
// Sheet opens after HTMX loads content via hx-on::after-swap.
// Closes on backdrop click (hx-on:click) or close button.
//
// Server returns HTML fragments:
// GET /api/orders/1001 →
// <div class="list">
// <div class="list-item">
// <span class="list-item-title">MacBook Pro 16"</span>
// <span>$2,499.00</span>
// </div>
// ...
// <div class="divider"></div>
// <div class="flex justify-between font-bold">
// <span>Total</span><span>$3,847.00</span>
// </div>
// </div> Delete Confirmation — Alert Dialog
Delete Account?
This action cannot be undone. All data will be permanently removed.
<!-- Button opens confirm dialog -->
<button class="btn color-danger"
hx-on:click="document.getElementById('confirm-alert').dataset.state='open'">
Delete Account
</button>
<!-- Alert dialog -->
<div id="confirm-alert" class="modal-backdrop" data-state="closed">
<div class="alert-dialog">
<div class="alert-dialog-header">
<h3 class="alert-dialog-title">Delete Account?</h3>
<p class="alert-dialog-description">This action cannot be undone. All data will be permanently removed.</p>
</div>
<div class="alert-dialog-footer">
<button class="btn btn-outline"
hx-on:click="this.closest('[data-state]').dataset.state='closed'">
Cancel
</button>
<button class="btn color-danger"
hx-delete="/api/account"
hx-target="#toast-container"
hx-swap="beforeend"
hx-on::after-request="this.closest('[data-state]').dataset.state='closed'">
Delete
</button>
</div>
</div>
</div> Requires: ux.min.css
<button class="inline-flex items-center justify-center h-11 px-4 bg-error text-error-content rounded-lg font-semibold hover:brightness-95 active:scale-[0.97] transition-all"
hx-on:click="document.getElementById('confirm-alert').dataset.state='open'">
Delete Account
</button>
<div id="confirm-alert" class="fixed inset-0 z-400 flex items-center justify-center bg-black/50 opacity-0 pointer-events-none transition-opacity duration-200" data-state="closed">
<div class="bg-base-100 rounded-box shadow-xl w-full max-w-sm mx-4 p-6 text-center">
<h3 class="text-lg font-semibold mb-2">Delete Account?</h3>
<p class="text-sm text-base-content/60 mb-6">This action cannot be undone. All data will be permanently removed.</p>
<div class="flex gap-3 justify-end">
<button class="h-10 px-4 rounded-lg text-sm font-medium border border-base-300 hover:bg-base-200 transition-colors"
hx-on:click="this.closest('[data-state]').dataset.state='closed'">Cancel</button>
<button class="h-10 px-4 rounded-lg text-sm font-medium bg-error text-error-content hover:brightness-95 transition-all"
hx-delete="/api/account"
hx-target="#toast-container"
hx-swap="beforeend"
hx-on::after-request="this.closest('[data-state]').dataset.state='closed'">Delete</button>
</div>
</div>
</div> Requires: tw.min.css
// The confirm dialog is pure CSS + data-state.
// hx-on:click opens it, hx-on::after-request closes it after the DELETE.
// The server response goes to #toast-container as a success/error toast. HTMX Tips with UX
Use data-state="open|closed" for all overlays. Toggle with HTMX events:
hx-on::after-swap="document.getElementById('my-modal').dataset.state='open'"
hx-on::after-request="this.closest('[data-state]').dataset.state='closed'" Use htmx-indicator class with UX spinners:
<div class="spinner spinner-sm htmx-indicator"></div> Or toggle .btn-loading on buttons:
hx-on::before-request="this.classList.add('btn-loading')"
hx-on::after-request="this.classList.remove('btn-loading')" Always return UX-styled HTML fragments from your server:
// Express example
app.get('/api/users', (req, res) => {
const users = getUsers(req.query);
res.send(`
<tr class="table-row">
<td>${user.name}</td>
<td><span class="badge color-success">Active</span></td>
</tr>
`);
}); Use hx-on::load to auto-remove toasts/alerts from server responses:
<div class="toast color-success"
hx-on::load="setTimeout(() => this.remove(), 3000)">
Saved!
</div>