2025-12-02 08:29:54 +00:00
# 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 )
2025-12-02 08:33:18 +00:00
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 )
2025-12-02 08:29:54 +00:00
---
## 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"
```
---
2025-12-02 08:33:18 +00:00
## 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
---
2025-12-02 08:29:54 +00:00
## 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