Files
nebula/HTMX.md

13 KiB

HTMX 4 Integration Guide for Nebula

This document details htmx 4 enhancements and patterns optimized for our Go/Templ server-driven architecture.

Why htmx 4?

htmx 4 introduces significant architectural improvements that align perfectly with Go-based server rendering:

  • Built-in SSE streaming - Native support for text/event-stream responses
  • Morphing swaps - Preserve DOM state during updates (forms, video, focus)
  • Partial tags - Explicit multi-target updates in single responses
  • View Transitions API - Smooth animated page transitions
  • Simplified extensions - Page-wide, event-based, zero performance penalty
  • Enhanced request headers - Better server-side request detection

Installation

<!-- htmx 4 core -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js"></script>

<!-- Extensions (bundled with htmx 4) -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js"></script>

Note: htmx 4 extensions are bundled in the htmx.org package at /dist/ext/. Do NOT use the separate htmx-ext-* packages which use the deprecated defineExtension API.


Core Patterns for Go/Templ

1. Partial Updates with <hx-partial>

The <hx-partial> tag enables multiple targeted updates in a single response - perfect for Go handlers that need to update several page sections.

Go Handler:

func handleAddToCart(w http.ResponseWriter, r *http.Request) {
    // Return multiple partials in one response
    components.CartPartials(cartData).Render(r.Context(), w)
}

Templ Component:

templ CartPartials(data CartData) {
    <hx-partial hx-target="#cart-count" hx-swap="innerHTML">
        <span>{ strconv.Itoa(data.Count) }</span>
    </hx-partial>
    
    <hx-partial hx-target="#cart-items" hx-swap="beforeend">
        @CartItem(data.NewItem)
    </hx-partial>
    
    <hx-partial hx-target="#cart-total" hx-swap="innerHTML">
        <span>{ data.FormattedTotal }</span>
    </hx-partial>
}

HTML Trigger:

<button hx-post="/cart/add" hx-vals='{"product_id": "123"}'>
    Add to Cart
</button>

2. Morphing Swaps (Preserve State)

Use innerMorph or outerMorph to update content while preserving:

  • Form input values
  • Focus state
  • Video/audio playback
  • Scroll position

Use Cases:

<!-- Live search that preserves input focus -->
<input type="search" 
       hx-get="/search" 
       hx-trigger="input delay:300ms"
       hx-target="#results"
       hx-swap="innerMorph">

<!-- Update list without losing scroll position -->
<div hx-get="/notifications" 
     hx-trigger="every 30s"
     hx-swap="outerMorph">
    <!-- notifications list -->
</div>

<!-- Dashboard that preserves playing media -->
<div id="dashboard" 
     hx-get="/dashboard/refresh"
     hx-trigger="every 60s"
     hx-swap="innerMorph">
</div>

Morph Control Attributes:

<!-- Skip morphing specific elements -->
<div hx-morph-skip>Never touch this</div>

<!-- Skip children but morph the element itself -->
<div hx-morph-skip-children>
    <video><!-- preserved --></video>
</div>

<!-- Ignore during morph comparisons -->
<div hx-morph-ignore>Ephemeral content</div>

3. View Transitions

Enable smooth animated transitions between page states.

Global Enable:

htmx.config.transitions = true;

Per-Element:

<button hx-get="/page" hx-swap="innerHTML transition:true">
    Navigate
</button>

<!-- With boosted links -->
<nav hx-boost="transition:true">
    <a href="/dashboard">Dashboard</a>
    <a href="/settings">Settings</a>
</nav>

CSS Customization:

::view-transition-group(*) {
    animation-duration: 150ms;
}

::view-transition-old(root) {
    animation: fade-out 150ms ease-out;
}

::view-transition-new(root) {
    animation: fade-in 150ms ease-in;
}

4. Streaming Responses (SSE)

htmx 4 has native SSE support - no extension needed.

Go Handler:

func handleProgress(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    
    flusher := w.(http.Flusher)
    
    for progress := 0; progress <= 100; progress += 10 {
        // Send HTML fragment
        fmt.Fprintf(w, "data: <div class=\"progress\" style=\"width: %d%%\"></div>\n\n", progress)
        flusher.Flush()
        time.Sleep(500 * time.Millisecond)
    }
    
    // Final update
    fmt.Fprintf(w, "data: <div class=\"complete\">Done!</div>\n\n")
}

HTML:

<button hx-get="/progress" hx-target="#progress-bar">
    Start Processing
</button>
<div id="progress-bar"></div>

Custom Events in SSE:

// Server sends custom event
fmt.Fprintf(w, "event: notification\n")
fmt.Fprintf(w, "data: {\"count\": 5}\n\n")
<!-- Client handles custom event -->
<div hx-get="/stream"
     hx-on:notification="updateBadge(event.detail.data)">
</div>

Request Detection in Go

htmx 4 Request Headers

Header Description Example Value
HX-Request Always "true" for htmx requests true
HX-Request-Type "partial" or "full" partial
HX-Target Target element selector #results
HX-Trigger Triggering element ID search-input
HX-Boosted Request from boosted element true
HX-Current-URL Current page URL /dashboard

Go Middleware/Helper:

func IsHTMXRequest(r *http.Request) bool {
    return r.Header.Get("HX-Request") == "true"
}

func IsPartialRequest(r *http.Request) bool {
    return r.Header.Get("HX-Request-Type") == "partial"
}

func GetHTMXTarget(r *http.Request) string {
    return r.Header.Get("HX-Target")
}

Pattern: Full Page vs Partial Response:

func handleDashboard(w http.ResponseWriter, r *http.Request) {
    data := getDashboardData()
    
    if IsHTMXRequest(r) && IsPartialRequest(r) {
        // Return only the content fragment
        views.DashboardContent(data).Render(r.Context(), w)
        return
    }
    
    // Return full page with layout
    views.DashboardPage(data).Render(r.Context(), w)
}

Response Headers from Go

Control client behavior via response headers:

func handleAction(w http.ResponseWriter, r *http.Request) {
    // Redirect client-side (no full page reload)
    w.Header().Set("HX-Location", "/new-page")
    
    // Push URL to browser history
    w.Header().Set("HX-Push-Url", "/updated-url")
    
    // Replace current URL (no history entry)
    w.Header().Set("HX-Replace-Url", "/current")
    
    // Trigger client-side events
    w.Header().Set("HX-Trigger", "itemAdded")
    w.Header().Set("HX-Trigger-After-Swap", "refreshCart")
    
    // Override swap behavior
    w.Header().Set("HX-Reswap", "outerHTML")
    w.Header().Set("HX-Retarget", "#different-target")
    
    // Force full page refresh
    w.Header().Set("HX-Refresh", "true")
}

Attribute Inheritance

htmx 4 uses explicit inheritance with the :inherited modifier.

Pattern: Shared Confirmation:

<div hx-confirm:inherited="Are you sure?">
    <button hx-delete="/item/1">Delete</button>
    <button hx-delete="/item/2">Delete</button>
    <button hx-delete="/item/3">Delete</button>
</div>

Pattern: Shared Target:

<div hx-target:inherited="#results" hx-swap:inherited="innerHTML">
    <button hx-get="/page/1">Page 1</button>
    <button hx-get="/page/2">Page 2</button>
    <button hx-get="/page/3">Page 3</button>
</div>

Enable Implicit Inheritance (htmx 2 behavior):

htmx.config.implicitInheritance = true;

Advanced Triggers

Trigger Modifiers

<!-- Debounce input -->
<input hx-get="/search" 
       hx-trigger="input delay:500ms"
       hx-target="#results">

<!-- Throttle scroll -->
<div hx-get="/load-more" 
     hx-trigger="scroll throttle:1s"
     hx-target="#items">

<!-- Once only -->
<div hx-get="/init" hx-trigger="load once">

<!-- Changed value only -->
<select hx-get="/filter" hx-trigger="change changed">

<!-- From another element -->
<button hx-get="/refresh" hx-trigger="click from:#other-button">

Special Events

<!-- Load when element enters DOM -->
<div hx-get="/content" hx-trigger="load">

<!-- Load when scrolled into view -->
<div hx-get="/lazy-content" hx-trigger="revealed">

<!-- Intersection observer with threshold -->
<div hx-get="/analytics" 
     hx-trigger="intersect threshold:0.5">

<!-- Polling -->
<div hx-get="/status" hx-trigger="every 5s">

<!-- Load polling (self-replacing) -->
<div hx-get="/progress" 
     hx-trigger="load delay:1s" 
     hx-swap="outerHTML">

Event Handling with hx-on

Inline event handlers without separate JavaScript:

<!-- After request completes -->
<form hx-post="/submit" 
      hx-on:htmx:after:request="this.reset()">

<!-- Before request starts -->
<button hx-post="/action"
        hx-on:htmx:before:request="showSpinner()">

<!-- On successful swap -->
<div hx-get="/content"
     hx-on:htmx:after:swap="initComponents()">

<!-- Custom SSE events -->
<div hx-get="/stream"
     hx-on:progress="updateBar(event.detail.data)">

Using find() helper:

<button hx-post="/save"
        hx-on:htmx:after:request="find('closest form').reset()">
    Save
</button>

Request Synchronization

Prevent race conditions with hx-sync:

<!-- Form submission aborts pending validation -->
<form hx-post="/submit" hx-sync="this:replace">
    <input hx-post="/validate" 
           hx-trigger="change"
           hx-sync="closest form">
    <button type="submit">Submit</button>
</form>

<!-- Queue requests -->
<button hx-post="/action" hx-sync="this:queue">

<!-- Drop new requests while one is pending -->
<button hx-post="/action" hx-sync="this:drop">

<!-- Abort pending, start new -->
<button hx-post="/action" hx-sync="this:abort">

Programmatic Abort:

<button id="action-btn" hx-post="/long-action">Start</button>
<button onclick="htmx.trigger('#action-btn', 'htmx:abort')">Cancel</button>

Loading Indicators

<!-- Inline indicator -->
<button hx-get="/data">
    Load Data
    <span class="htmx-indicator">Loading...</span>
</button>

<!-- External indicator -->
<button hx-get="/data" hx-indicator="#spinner">
    Load Data
</button>
<div id="spinner" class="htmx-indicator">
    <wa-spinner></wa-spinner>
</div>

<!-- Disable during request -->
<button hx-get="/data" hx-disabled-elt="this">
    Load (disables while loading)
</button>

<!-- Disable multiple elements -->
<form hx-post="/submit" hx-disabled-elt="find input, find button">

CSS for indicators:

.htmx-indicator {
    opacity: 0;
    transition: opacity 200ms ease-in;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
    opacity: 1;
}

Boosting & Navigation

Convert standard links/forms to AJAX:

<!-- Boost all links in nav -->
<nav hx-boost:inherited="true">
    <a href="/dashboard">Dashboard</a>
    <a href="/settings">Settings</a>
</nav>

<!-- With history support -->
<a href="/page" hx-boost="true" hx-push-url="true">
    Navigate with History
</a>

<!-- Boost with transitions -->
<main hx-boost="transition:true">
    <a href="/other">Smooth Navigate</a>
</main>

Preload Extension

Preload content on hover/mousedown for instant navigation:

<!-- Preload on mousedown (50ms head start) -->
<a href="/page" preload="mousedown">Fast Link</a>

<!-- Preload on hover -->
<a href="/page" preload>Preloaded Link</a>

<!-- Preload with custom trigger -->
<button hx-get="/content" preload="mousedown">
    Load Content
</button>

Confirmation Dialogs

<!-- Simple confirm -->
<button hx-delete="/item" hx-confirm="Delete this item?">
    Delete
</button>

<!-- Custom confirm with JavaScript -->
<button hx-delete="/account" 
        hx-confirm="js:confirmDelete()">
    Delete Account
</button>

<script>
async function confirmDelete() {
    const result = await showCustomDialog({
        title: 'Delete Account?',
        message: 'This cannot be undone.',
        confirmText: 'Delete',
        variant: 'danger'
    });
    return result.confirmed;
}
</script>

Best Practices for Go/Templ

1. Use Partials for Multi-Target Updates

Instead of multiple requests or complex OOB swaps, return <hx-partial> tags.

2. Leverage Morphing for Stateful Components

Use innerMorph/outerMorph for forms, media players, and interactive widgets.

3. Detect Request Type Server-Side

Return minimal HTML fragments for partial requests, full pages for direct navigation.

4. Use Response Headers for Side Effects

Trigger client events, update URLs, and control swapping via headers.

5. Preload Critical Paths

Add preload="mousedown" to navigation links for perceived instant loading.

6. Stream Long Operations

Use SSE (text/event-stream) for progress indicators and real-time updates.


Configuration Reference

// Enable all htmx 4 features
htmx.config.transitions = true;           // View Transitions API
htmx.config.implicitInheritance = false;  // Explicit :inherited (default)
htmx.config.selfRequestsOnly = true;      // Security: same-origin only
htmx.config.timeout = 0;                  // Request timeout (0 = none)
htmx.config.historyCacheSize = 10;        // History cache entries

Via Meta Tag:

<meta name="htmx-config" content='{
    "transitions": true,
    "selfRequestsOnly": true
}'>