- 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
21 KiB
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
- Core Concepts
- Out-of-Band Swaps (OOB)
- Language Switch Pattern
- Toggle Patterns
- Contact Form Pattern
- Skeleton Loaders
- HTML Invoker Commands API
- Lazy Loading Web Components
- 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
<!-- 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.
<!-- 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
- User clicks language button (EN or ES)
- HTMX sends request to
/switch-language?lang=es - Server renders new content for both pages in the selected language
- OOB swaps update both page containers atomically
The Template Structure
<!-- 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
_="on htmx:afterSettle wait 100ms then remove .loading from me"
This hyperscript:
- Listens for
htmx:afterSettleevent (content fully swapped) - Waits 100ms (for CSS transitions)
- Removes
.loadingclass (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:
- The server just needs to set a cookie
- The frontend uses hyperscript to toggle UI state
<!-- 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
// 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
hx-swap="none"tells HTMX to ignore the response body- The cookie gets set (browser handles this automatically)
- Hyperscript handles UI changes locally
- 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
<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
<!-- 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
<!-- 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
hx-indicator="#contact-spinner"
HTMX automatically:
- Adds
.htmx-requestclass to indicator during request - 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:
<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
/* 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:
// Add .loading to show skeletons
document.querySelectorAll('.cv-page-content-wrapper').forEach(el => {
el.classList.add('loading');
});
After content loads (via hyperscript):
_="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:
<!-- 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:
<!-- 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
<!-- 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
- No JavaScript - Pure HTML declarative syntax
- Accessibility - Built-in keyboard and screen reader support
- Reduced Errors - No typos in element IDs within JavaScript
- Cleaner Templates - Removes onclick clutter
- Progressive Enhancement - Graceful degradation in older browsers
Fallback for Older Browsers
If you need to support browsers without Invoker Commands:
<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:
// 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
<!-- 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 |
// ❌ 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:
// 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
- Use Placeholder Containers - Empty div ready for component injection
- Prevent Double Loading - Track loading state with flags
- Bundle Dependencies - Use
?bundleparameter on esm.sh - Cache First Load - Browser caches subsequent uses automatically
- Multiple Triggers - Support keyboard AND button triggers
- 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
<!-- 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:
<!-- 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:
<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:
<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:
<button hx-indicator=".spinner">
Submit <span class="spinner htmx-indicator">...</span>
</button>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
5. Return Proper HTTP Status Codes
200 OK- Success with content204 No Content- Success, no body needed422 Unprocessable Entity- Validation errors- HTMX handles these gracefully
Further Reading
- HTMX Documentation
- Hyperscript Documentation
- HTMX Examples
- Project doc:
doc/2-MODERN-WEB-TECHNIQUES.md- Full techniques reference - Project doc:
doc/4-HYPERSCRIPT-RULES.md- Hyperscript patterns