db642c7cc2
- Document HTML Invoker Commands API (commandfor/command) - Document lazy loading pattern for web components - Include CDN comparison (esm.sh ?bundle vs others) - Add CSP configuration notes - Performance metrics before/after
763 lines
21 KiB
Markdown
763 lines
21 KiB
Markdown
# HTMX Learning Guide
|
|
|
|
**Last Updated**: December 2024
|
|
|
|
## Overview
|
|
|
|
This document explains HTMX patterns used in this CV website project, with practical examples from the codebase. Use this as a learning resource for understanding HTMX concepts.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Core Concepts](#core-concepts)
|
|
2. [Out-of-Band Swaps (OOB)](#out-of-band-swaps-oob)
|
|
3. [Language Switch Pattern](#language-switch-pattern)
|
|
4. [Toggle Patterns](#toggle-patterns)
|
|
5. [Contact Form Pattern](#contact-form-pattern)
|
|
6. [Skeleton Loaders](#skeleton-loaders)
|
|
7. [HTML Invoker Commands API](#html-invoker-commands-api)
|
|
8. [Lazy Loading Web Components](#lazy-loading-web-components)
|
|
9. [Common Attributes Reference](#common-attributes-reference)
|
|
|
|
---
|
|
|
|
## Core Concepts
|
|
|
|
### What is HTMX?
|
|
|
|
HTMX allows you to build modern user interfaces with simple HTML attributes instead of JavaScript. It extends HTML with attributes that enable:
|
|
|
|
- **AJAX requests** from any element (not just links/forms)
|
|
- **Partial page updates** without full page reloads
|
|
- **CSS transitions** on swaps
|
|
- **WebSocket/SSE** support
|
|
|
|
### Basic Example
|
|
|
|
```html
|
|
<!-- Button that fetches content and replaces a target -->
|
|
<button hx-get="/api/content"
|
|
hx-target="#content-area"
|
|
hx-swap="innerHTML">
|
|
Load Content
|
|
</button>
|
|
|
|
<div id="content-area">
|
|
<!-- Content will be replaced here -->
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## Out-of-Band Swaps (OOB)
|
|
|
|
### The Problem
|
|
|
|
Normal HTMX swaps can only update ONE target element. But what if you need to update MULTIPLE elements with a single request?
|
|
|
|
**Example**: When switching languages, we need to update:
|
|
- Language selector buttons (show which is active)
|
|
- Page 1 content (header, experience, education)
|
|
- Page 2 content (awards, projects, courses)
|
|
- Footer
|
|
|
|
### The Solution: `hx-swap-oob`
|
|
|
|
OOB (Out-of-Band) swaps let you update ANY element on the page by including it in your response with a matching `id`.
|
|
|
|
```html
|
|
<!-- Server response can include multiple elements -->
|
|
<!-- Main response goes to hx-target -->
|
|
<div>Main content here</div>
|
|
|
|
<!-- OOB elements update by matching ID -->
|
|
<div id="sidebar" hx-swap-oob="outerHTML">
|
|
New sidebar content
|
|
</div>
|
|
|
|
<div id="notification" hx-swap-oob="innerHTML">
|
|
5 new messages
|
|
</div>
|
|
```
|
|
|
|
### OOB Swap Types
|
|
|
|
| Attribute | Effect |
|
|
|-----------|--------|
|
|
| `hx-swap-oob="true"` | Replace inner HTML |
|
|
| `hx-swap-oob="innerHTML"` | Replace inner HTML |
|
|
| `hx-swap-oob="outerHTML"` | Replace entire element including itself |
|
|
| `hx-swap-oob="beforebegin"` | Insert before element |
|
|
| `hx-swap-oob="afterend"` | Insert after element |
|
|
|
|
---
|
|
|
|
## Language Switch Pattern
|
|
|
|
**File**: `templates/language-switch.html`
|
|
|
|
This is the most complex HTMX pattern in the project - updating the entire CV content when switching languages.
|
|
|
|
### How It Works
|
|
|
|
1. **User clicks language button** (EN or ES)
|
|
2. **HTMX sends request** to `/switch-language?lang=es`
|
|
3. **Server renders new content** for both pages in the selected language
|
|
4. **OOB swaps update** both page containers atomically
|
|
|
|
### The Template Structure
|
|
|
|
```html
|
|
<!-- templates/language-switch.html -->
|
|
|
|
<!-- First: Update language selector buttons (OOB) -->
|
|
<div id="language-selector-container" hx-swap-oob="outerHTML">
|
|
<button class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
|
hx-post="/switch-language?lang=en">EN</button>
|
|
<button class="lang-btn {{if eq .Lang "es"}}active{{end}}"
|
|
hx-post="/switch-language?lang=es">ES</button>
|
|
</div>
|
|
|
|
<!-- Second: Update Page 1 content (OOB) -->
|
|
<div id="cv-inner-content-page-1"
|
|
class="cv-page-content-wrapper"
|
|
hx-swap-oob="outerHTML"
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me">
|
|
|
|
{{template "title-badges" .}}
|
|
|
|
<div class="page-content">
|
|
<!-- Left Sidebar -->
|
|
<aside class="cv-sidebar cv-sidebar-left">
|
|
{{range .SkillsLeft}}
|
|
<section>{{.Category}}: {{range .Items}}{{.}}{{end}}</section>
|
|
{{end}}
|
|
</aside>
|
|
|
|
<!-- Main Content -->
|
|
<main class="cv-main">
|
|
{{template "section-header" .}}
|
|
{{template "section-education" .}}
|
|
{{template "section-experience" .}}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Third: Update Page 2 content (OOB) -->
|
|
<div id="cv-inner-content-page-2"
|
|
class="cv-page-content-wrapper"
|
|
hx-swap-oob="outerHTML"
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me">
|
|
|
|
{{template "title-badges" .}}
|
|
|
|
<div class="page-content">
|
|
<!-- Main Content -->
|
|
<main class="cv-main">
|
|
{{template "section-awards" .}}
|
|
{{template "section-projects" .}}
|
|
{{template "section-courses" .}}
|
|
</main>
|
|
|
|
<!-- Right Sidebar -->
|
|
<aside class="cv-sidebar cv-sidebar-right">
|
|
{{range .SkillsRight}}
|
|
<section>{{.Category}}: {{range .Items}}{{.}}{{end}}</section>
|
|
{{end}}
|
|
</aside>
|
|
</div>
|
|
|
|
{{template "cv-footer" .}}
|
|
</div>
|
|
```
|
|
|
|
### Why Two Separate Page Divs?
|
|
|
|
The CV is designed as a **2-page printable document**:
|
|
|
|
| Page 1 | Page 2 |
|
|
|--------|--------|
|
|
| Header | Awards |
|
|
| Education | Projects |
|
|
| Skills Summary | Courses |
|
|
| Experience | Languages |
|
|
| Left Sidebar (Skills) | References |
|
|
| | Right Sidebar (More Skills) |
|
|
| | Footer |
|
|
|
|
Each page has its own layout grid and sidebar positioning, so they need separate containers.
|
|
|
|
### The Hyperscript Integration
|
|
|
|
```html
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me"
|
|
```
|
|
|
|
This hyperscript:
|
|
1. Listens for `htmx:afterSettle` event (content fully swapped)
|
|
2. Waits 100ms (for CSS transitions)
|
|
3. Removes `.loading` class (hides skeleton, shows content)
|
|
|
|
### Visual Flow
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ User clicks "ES" │
|
|
└─────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ hx-post="/switch-language?lang=es" │
|
|
│ hx-target="#cv-content" │
|
|
│ hx-swap="none" ← Response body ignored for main swap │
|
|
└─────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Server Response Contains: │
|
|
│ │
|
|
│ 1. Language selector (hx-swap-oob="outerHTML") │
|
|
│ 2. Page 1 content (hx-swap-oob="outerHTML") │
|
|
│ 3. Page 2 content (hx-swap-oob="outerHTML") │
|
|
└─────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ HTMX matches IDs and swaps all three simultaneously │
|
|
│ → Atomic update, no flicker │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Toggle Patterns
|
|
|
|
### Pattern: `hx-swap="none"` + Cookies
|
|
|
|
Toggles (length, theme, icons) don't need server-rendered content because:
|
|
1. The server just needs to **set a cookie**
|
|
2. The frontend uses **hyperscript** to toggle UI state
|
|
|
|
```html
|
|
<!-- Toggle button -->
|
|
<button hx-post="/toggle/length"
|
|
hx-swap="none"
|
|
_="on htmx:afterRequest toggle .cv-short .cv-long on #cv-container">
|
|
Toggle Length
|
|
</button>
|
|
```
|
|
|
|
### Server Handler
|
|
|
|
```go
|
|
// Returns 204 No Content - body is ignored anyway
|
|
func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
|
|
// Toggle cookie value
|
|
prefs := middleware.GetPreferences(r)
|
|
newLength := "long"
|
|
if prefs.CVLength == "long" {
|
|
newLength = "short"
|
|
}
|
|
|
|
// Set cookie
|
|
middleware.SetPreferenceCookie(w, "cv-length", newLength)
|
|
|
|
// Return 204 - no body needed
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
```
|
|
|
|
### Why This Works
|
|
|
|
1. `hx-swap="none"` tells HTMX to **ignore the response body**
|
|
2. The **cookie gets set** (browser handles this automatically)
|
|
3. **Hyperscript handles UI** changes locally
|
|
4. On **next page load**, server reads cookie and renders correctly
|
|
|
|
---
|
|
|
|
## Contact Form Pattern
|
|
|
|
**File**: `templates/partials/contact/contact-form.html`
|
|
|
|
### Pattern: `hx-target` + Partial Replacement
|
|
|
|
```html
|
|
<form hx-post="/api/contact"
|
|
hx-target="#contact-form-container"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#contact-spinner">
|
|
|
|
<input type="email" name="email" required>
|
|
<textarea name="message" required></textarea>
|
|
|
|
<button type="submit">
|
|
Send
|
|
<span id="contact-spinner" class="htmx-indicator">...</span>
|
|
</button>
|
|
</form>
|
|
```
|
|
|
|
### Server Responses
|
|
|
|
**Success**: Returns success partial
|
|
```html
|
|
<!-- templates/partials/contact/contact-success.html -->
|
|
<div class="contact-success">
|
|
<iconify-icon icon="mdi:check-circle"></iconify-icon>
|
|
<p>Message sent successfully!</p>
|
|
</div>
|
|
```
|
|
|
|
**Error**: Returns error partial
|
|
```html
|
|
<!-- templates/partials/contact/contact-error.html -->
|
|
<div class="contact-error">
|
|
<iconify-icon icon="mdi:alert-circle"></iconify-icon>
|
|
<p>{{.ErrorMessage}}</p>
|
|
</div>
|
|
```
|
|
|
|
### The `hx-indicator` Pattern
|
|
|
|
```html
|
|
hx-indicator="#contact-spinner"
|
|
```
|
|
|
|
HTMX automatically:
|
|
1. Adds `.htmx-request` class to indicator during request
|
|
2. Shows spinner via CSS: `.htmx-indicator { display: none } .htmx-request .htmx-indicator { display: inline }`
|
|
|
|
---
|
|
|
|
## Skeleton Loaders
|
|
|
|
### Pattern: CSS Class Toggle with Hyperscript
|
|
|
|
The skeleton loader system uses a **dual-state structure**:
|
|
|
|
```html
|
|
<div class="component-wrapper">
|
|
<!-- Real content - visible by default -->
|
|
<div class="actual-content">
|
|
<h1>John Smith</h1>
|
|
<p>Software Engineer</p>
|
|
</div>
|
|
|
|
<!-- Skeleton - hidden by default -->
|
|
<div class="skeleton-content">
|
|
<div class="skeleton skeleton-name"></div>
|
|
<div class="skeleton skeleton-text"></div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### CSS State Control
|
|
|
|
```css
|
|
/* Default: Show content, hide skeleton */
|
|
.component-wrapper .actual-content { opacity: 1; }
|
|
.component-wrapper .skeleton-content { opacity: 0; pointer-events: none; }
|
|
|
|
/* Loading: Hide content, show skeleton */
|
|
.component-wrapper.loading .actual-content { opacity: 0; }
|
|
.component-wrapper.loading .skeleton-content { opacity: 1; }
|
|
```
|
|
|
|
### Triggering Loading State
|
|
|
|
Before language switch:
|
|
```javascript
|
|
// Add .loading to show skeletons
|
|
document.querySelectorAll('.cv-page-content-wrapper').forEach(el => {
|
|
el.classList.add('loading');
|
|
});
|
|
```
|
|
|
|
After content loads (via hyperscript):
|
|
```html
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me"
|
|
```
|
|
|
|
---
|
|
|
|
## HTML Invoker Commands API
|
|
|
|
**Browser Support**: Chrome/Edge 135+, Firefox Nightly, Safari TP
|
|
|
|
### The Problem
|
|
|
|
Opening and closing `<dialog>` elements traditionally requires JavaScript:
|
|
|
|
```html
|
|
<!-- Old way - onclick handlers everywhere -->
|
|
<button onclick="document.getElementById('my-modal').showModal()">
|
|
Open Modal
|
|
</button>
|
|
|
|
<dialog id="my-modal">
|
|
<button onclick="document.getElementById('my-modal').close()">
|
|
Close
|
|
</button>
|
|
</dialog>
|
|
```
|
|
|
|
This is verbose, error-prone, and mixes behavior with markup.
|
|
|
|
### The Solution: `commandfor` + `command`
|
|
|
|
The new HTML Invoker Commands API provides declarative modal control:
|
|
|
|
```html
|
|
<!-- New way - pure HTML attributes -->
|
|
<button commandfor="my-modal" command="show-modal">
|
|
Open Modal
|
|
</button>
|
|
|
|
<dialog id="my-modal">
|
|
<button commandfor="my-modal" command="close">
|
|
Close
|
|
</button>
|
|
</dialog>
|
|
```
|
|
|
|
### Command Values
|
|
|
|
| Command | Effect | Target Element |
|
|
|---------|--------|----------------|
|
|
| `show-modal` | Opens dialog as modal | `<dialog>` |
|
|
| `close` | Closes dialog | `<dialog>` |
|
|
| `show-popover` | Shows popover | `[popover]` |
|
|
| `hide-popover` | Hides popover | `[popover]` |
|
|
| `toggle-popover` | Toggles popover | `[popover]` |
|
|
|
|
### Project Implementation
|
|
|
|
**Files**: `templates/partials/widgets/*.html`, `templates/partials/modals/*.html`
|
|
|
|
```html
|
|
<!-- Info button opens info modal -->
|
|
<button id="info-button"
|
|
commandfor="info-modal"
|
|
command="show-modal"
|
|
aria-label="Show information">
|
|
<iconify-icon icon="mdi:information-outline"></iconify-icon>
|
|
</button>
|
|
|
|
<!-- Modal with close button -->
|
|
<dialog id="info-modal" class="info-modal">
|
|
<div class="info-modal-content">
|
|
<button class="info-modal-close"
|
|
commandfor="info-modal"
|
|
command="close"
|
|
aria-label="Close">
|
|
<iconify-icon icon="mdi:close"></iconify-icon>
|
|
</button>
|
|
<!-- Modal content -->
|
|
</div>
|
|
</dialog>
|
|
```
|
|
|
|
### Benefits
|
|
|
|
1. **No JavaScript** - Pure HTML declarative syntax
|
|
2. **Accessibility** - Built-in keyboard and screen reader support
|
|
3. **Reduced Errors** - No typos in element IDs within JavaScript
|
|
4. **Cleaner Templates** - Removes onclick clutter
|
|
5. **Progressive Enhancement** - Graceful degradation in older browsers
|
|
|
|
### Fallback for Older Browsers
|
|
|
|
If you need to support browsers without Invoker Commands:
|
|
|
|
```html
|
|
<button commandfor="my-modal"
|
|
command="show-modal"
|
|
onclick="this.commandfor || document.getElementById('my-modal').showModal()">
|
|
Open Modal
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
## Lazy Loading Web Components
|
|
|
|
### The Problem
|
|
|
|
Heavy web components (like ninja-keys command palette) add significant initial load time even when users may never use them:
|
|
|
|
```
|
|
Initial Load: 81 module requests, ~300KB, 2+ seconds
|
|
```
|
|
|
|
### The Solution: Dynamic Import on Demand
|
|
|
|
Only load the component when the user actually needs it:
|
|
|
|
```javascript
|
|
// Don't import at top of file
|
|
// import 'ninja-keys'; // ❌ Loads immediately
|
|
|
|
// Instead, lazy load on first use
|
|
let loaded = false;
|
|
|
|
async function loadNinjaKeys() {
|
|
if (loaded) return;
|
|
|
|
// Dynamic import - only fetches when called
|
|
await import('https://esm.sh/ninja-keys@1.2.2?bundle');
|
|
|
|
// Create element after module loads
|
|
const container = document.getElementById('cmd-k-container');
|
|
const ninjaKeys = document.createElement('ninja-keys');
|
|
ninjaKeys.id = 'cmd-k-bar';
|
|
container.appendChild(ninjaKeys);
|
|
|
|
loaded = true;
|
|
}
|
|
|
|
// Trigger on user action
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
loadNinjaKeys();
|
|
}
|
|
});
|
|
```
|
|
|
|
### Project Implementation
|
|
|
|
**File**: `templates/partials/layout/body-scripts.html`
|
|
|
|
```html
|
|
<!-- Placeholder container (always present, empty) -->
|
|
<div id="cmd-k-container"></div>
|
|
|
|
<script>
|
|
(function() {
|
|
let ninjaLoaded = false;
|
|
let ninjaLoading = false;
|
|
|
|
async function loadNinjaKeys() {
|
|
if (ninjaLoaded || ninjaLoading) return;
|
|
ninjaLoading = true;
|
|
|
|
// Use esm.sh with ?bundle for single-file delivery
|
|
await import('https://esm.sh/ninja-keys@1.2.2?bundle');
|
|
|
|
// Create element
|
|
const container = document.getElementById('cmd-k-container');
|
|
const ninjaKeys = document.createElement('ninja-keys');
|
|
ninjaKeys.id = 'cmd-k-bar';
|
|
ninjaKeys.placeholder = 'Type a command or search...';
|
|
ninjaKeys.hideBreadcrumbs = true;
|
|
container.appendChild(ninjaKeys);
|
|
|
|
// Load initialization script
|
|
const script = document.createElement('script');
|
|
script.src = '/static/js/ninja-keys-init.js';
|
|
document.body.appendChild(script);
|
|
|
|
ninjaLoaded = true;
|
|
ninjaLoading = false;
|
|
|
|
// Open after brief initialization delay
|
|
setTimeout(() => ninjaKeys.open(), 100);
|
|
}
|
|
|
|
function openNinjaKeys() {
|
|
const nk = document.getElementById('cmd-k-bar');
|
|
if (nk && typeof nk.open === 'function') {
|
|
nk.open();
|
|
}
|
|
}
|
|
|
|
// CMD+K / Ctrl+K keyboard shortcut
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
ninjaLoaded ? openNinjaKeys() : loadNinjaKeys();
|
|
}
|
|
});
|
|
|
|
// Button click trigger
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.closest('#cmd-k-button, .cmd-k-trigger')) {
|
|
e.preventDefault();
|
|
ninjaLoaded ? openNinjaKeys() : loadNinjaKeys();
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
```
|
|
|
|
### CDN Choice: esm.sh with ?bundle
|
|
|
|
| CDN | Requests | Why |
|
|
|-----|----------|-----|
|
|
| unpkg.com | 80+ (redirect chains) | ❌ Follows all peer deps |
|
|
| esm.sh | 80+ (without bundle) | ❌ Resolves all imports |
|
|
| esm.sh?bundle | 2-3 | ✅ Pre-bundled single file |
|
|
| jsdelivr | 1 | ✅ Also good option |
|
|
|
|
```javascript
|
|
// ❌ Triggers 80+ module requests
|
|
await import('https://esm.sh/ninja-keys@1.2.2');
|
|
|
|
// ✅ Single bundled file
|
|
await import('https://esm.sh/ninja-keys@1.2.2?bundle');
|
|
```
|
|
|
|
### CSP Configuration
|
|
|
|
Remember to add your CDN to Content Security Policy:
|
|
|
|
```go
|
|
// internal/middleware/security.go
|
|
csp := "default-src 'self'; " +
|
|
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdn.jsdelivr.net; " +
|
|
// ...
|
|
```
|
|
|
|
### Performance Results
|
|
|
|
| Metric | Before | After |
|
|
|--------|--------|-------|
|
|
| Initial requests | 81 | 0 |
|
|
| Initial load time | +2.1s | 0ms |
|
|
| On CMD+K | 0 | 3 requests |
|
|
| Subsequent uses | 0 | 0 (cached) |
|
|
|
|
### Best Practices
|
|
|
|
1. **Use Placeholder Containers** - Empty div ready for component injection
|
|
2. **Prevent Double Loading** - Track loading state with flags
|
|
3. **Bundle Dependencies** - Use `?bundle` parameter on esm.sh
|
|
4. **Cache First Load** - Browser caches subsequent uses automatically
|
|
5. **Multiple Triggers** - Support keyboard AND button triggers
|
|
6. **Initialization Delay** - Wait briefly after element creation for setup
|
|
|
|
---
|
|
|
|
## Common Attributes Reference
|
|
|
|
### Request Attributes
|
|
|
|
| Attribute | Description | Example |
|
|
|-----------|-------------|---------|
|
|
| `hx-get` | GET request | `hx-get="/api/data"` |
|
|
| `hx-post` | POST request | `hx-post="/api/submit"` |
|
|
| `hx-put` | PUT request | `hx-put="/api/update/1"` |
|
|
| `hx-delete` | DELETE request | `hx-delete="/api/item/1"` |
|
|
|
|
### Target & Swap Attributes
|
|
|
|
| Attribute | Description | Example |
|
|
|-----------|-------------|---------|
|
|
| `hx-target` | Element to update | `hx-target="#results"` |
|
|
| `hx-swap` | How to swap content | `hx-swap="innerHTML"` |
|
|
| `hx-swap-oob` | Out-of-band swap | `hx-swap-oob="outerHTML"` |
|
|
|
|
### Swap Values
|
|
|
|
| Value | Effect |
|
|
|-------|--------|
|
|
| `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 |
|
|
| `none` | Don't swap (use for side effects only) |
|
|
|
|
### Trigger Attributes
|
|
|
|
| Attribute | Description | Example |
|
|
|-----------|-------------|---------|
|
|
| `hx-trigger` | Event to trigger request | `hx-trigger="click"` |
|
|
| `hx-indicator` | Loading indicator | `hx-indicator="#spinner"` |
|
|
| `hx-confirm` | Confirmation dialog | `hx-confirm="Are you sure?"` |
|
|
|
|
### Special Triggers
|
|
|
|
```html
|
|
<!-- Trigger on input with 500ms debounce -->
|
|
hx-trigger="input changed delay:500ms"
|
|
|
|
<!-- Trigger on scroll into view -->
|
|
hx-trigger="revealed"
|
|
|
|
<!-- Trigger on page load -->
|
|
hx-trigger="load"
|
|
|
|
<!-- Trigger on intersection observer -->
|
|
hx-trigger="intersect"
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices Learned
|
|
|
|
### 1. Use OOB for Multi-Element Updates
|
|
|
|
Instead of multiple requests, use OOB swaps:
|
|
|
|
```html
|
|
<!-- Server returns multiple elements in one response -->
|
|
<div id="main-content">Updated main</div>
|
|
<div id="sidebar" hx-swap-oob="outerHTML">Updated sidebar</div>
|
|
<div id="notification" hx-swap-oob="innerHTML">New notification</div>
|
|
```
|
|
|
|
### 2. Use `hx-swap="none"` for Side Effects
|
|
|
|
When you only need server-side effects (cookies, database), skip the swap:
|
|
|
|
```html
|
|
<button hx-post="/api/favorite"
|
|
hx-swap="none"
|
|
_="on htmx:afterRequest toggle .favorited on me">
|
|
```
|
|
|
|
### 3. Combine HTMX + Hyperscript
|
|
|
|
HTMX handles server communication; hyperscript handles local UI:
|
|
|
|
```html
|
|
<button hx-post="/toggle/theme"
|
|
hx-swap="none"
|
|
_="on htmx:afterRequest
|
|
toggle .dark-theme .light-theme on body">
|
|
```
|
|
|
|
### 4. Use CSS for Loading States
|
|
|
|
Instead of JavaScript spinners:
|
|
|
|
```html
|
|
<button hx-indicator=".spinner">
|
|
Submit <span class="spinner htmx-indicator">...</span>
|
|
</button>
|
|
```
|
|
|
|
```css
|
|
.htmx-indicator { display: none; }
|
|
.htmx-request .htmx-indicator { display: inline; }
|
|
```
|
|
|
|
### 5. Return Proper HTTP Status Codes
|
|
|
|
- `200 OK` - Success with content
|
|
- `204 No Content` - Success, no body needed
|
|
- `422 Unprocessable Entity` - Validation errors
|
|
- HTMX handles these gracefully
|
|
|
|
---
|
|
|
|
## Further Reading
|
|
|
|
- [HTMX Documentation](https://htmx.org/docs/)
|
|
- [Hyperscript Documentation](https://hyperscript.org/docs/)
|
|
- [HTMX Examples](https://htmx.org/examples/)
|
|
- Project doc: `doc/2-MODERN-WEB-TECHNIQUES.md` - Full techniques reference
|
|
- Project doc: `doc/4-HYPERSCRIPT-RULES.md` - Hyperscript patterns
|