Files
cv-site/doc/20-HTMX-LEARNING.md
T
juanatsap db642c7cc2 docs: Add HTML Invoker Commands and Lazy Loading sections
- 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
2025-12-02 08:33:18 +00:00

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

  1. Core Concepts
  2. Out-of-Band Swaps (OOB)
  3. Language Switch Pattern
  4. Toggle Patterns
  5. Contact Form Pattern
  6. Skeleton Loaders
  7. HTML Invoker Commands API
  8. Lazy Loading Web Components
  9. 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

  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

<!-- 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:

  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
<!-- 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

  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

<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:

  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:

<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

  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:

<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

  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

<!-- 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 content
  • 204 No Content - Success, no body needed
  • 422 Unprocessable Entity - Validation errors
  • HTMX handles these gracefully

Further Reading