Skip to content

Pattern Showcase

Every HTMX and Alpine.js pattern this starter supports, live and grouped by what it teaches. Each demo is the real thing — the source shown next to it is what serves it.

Fetch & swap

The core loop: an attribute fires a request, the server renders HTML, HTMX places it.

Partial swap

Click a button, replace a fragment. The simplest HTMX pattern — a GET returns rendered HTML and hx-target places it.

hx-get hx-target hx-swap
Click the button — the server replaces this text.
<button hx-get="/patterns/api/swap"
  hx-target="#swap-demo" hx-swap="innerHTML">
  Swap this fragment
</button>
<div id="swap-demo">...</div>
func PatternSwap(w http.ResponseWriter, r *http.Request) {
  view.Render(w, r, http.StatusOK,
    partials.PatternSwapResult())
}

Skeleton loading

A CSS skeleton renders instantly; HTMX replaces it with real content on a delayed load trigger.

hx-get hx-trigger="load delay:1.5s"
<div hx-get="/patterns/api/skeleton"
  hx-trigger="load delay:1.5s">
  <div class="animate-pulse">...</div>
</div>
// The server returns immediately — the
// delay is client-side, so no goroutine
// sleeps on a demo's behalf.
view.Render(w, r, http.StatusOK,
  partials.PatternSkeletonContent())

Tabs

Tab buttons fetch server-rendered panels; Alpine handles the client-only variant (see the source panel you're using).

hx-get hx-target x-datax-show
Pick a tab — its panel is rendered server-side.
<button hx-get="/patterns/api/tab/templ"
  hx-target="#tab-demo-panel">templ</button>
<div id="tab-demo-panel"></div>
name := chi.URLParam(r, "name")
content, ok := patternTabs[name]
if !ok { http.NotFound(w, r); return }
view.Render(w, r, http.StatusOK,
  partials.PatternTabPanel(props))

Polling

A live server value without WebSockets: the element re-fetches itself on a fixed interval — the whole real-time stack is one attribute.

hx-trigger="load, every 5s" hx-get
Fetching server time...
<div hx-get="/patterns/api/time"
  hx-trigger="load, every 5s"
  hx-swap="innerHTML">...</div>
now := time.Now().Format("15:04:05")
view.Render(w, r, http.StatusOK,
  partials.PatternTimeTick(now, load))

Search & lists

Server-filtered results with debounce, indicators, and pagination that loads itself.

Infinite scroll

Scroll to the bottom and the next page loads automatically; the sentinel row replaces itself with each new page. Inside a scrollable container the trigger is intersect, not revealed — revealed only watches the window scroll.

hx-get hx-trigger="intersect once" hx-swap="outerHTML"
  • Scroll me into view...
<li hx-get="/patterns/api/scroll?page=2"
  hx-trigger="intersect once" hx-swap="outerHTML">
  Loading more...
</li>
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
items, next := stubPage(page)
view.Render(w, r, http.StatusOK,
  partials.PatternScrollPage(props))

Active search (typeahead)

Like live search, plus a loading indicator that HTMX toggles for you while the request is in flight.

hx-get hx-trigger="keyup changed delay:200ms" hx-indicator
Searching...
<input hx-get="/patterns/api/typeahead"
  hx-trigger="keyup changed delay:200ms"
  hx-indicator="#typeahead-indicator"/>
<span id="typeahead-indicator"
  class="htmx-indicator">Searching...</span>
// Identical server shape to live search —
// the indicator is pure client wiring.

Forms & actions

Validation, editing, confirmation, and honest in-flight feedback — all server-round-trip.

Click to edit

Click text to turn it into an edit form; saving swaps the display view back in. Two endpoints, zero client state.

hx-get hx-put hx-swap
Click me, then save your edit
<div hx-get="/patterns/api/edit/1"
  hx-swap="outerHTML">{ value }</div>
<form hx-put="/patterns/api/edit/1"
  hx-swap="outerHTML">...</form>
// GET returns the form; PUT saves and
// returns the display view.
value := r.FormValue("value")
view.Render(w, r, http.StatusOK,
  partials.PatternEditDisplay(props))

Inline validation

Alpine flags the field once you leave it (not on the first keystroke), then clears live as you fix it; the server re-checks on submit and returns field errors into the same markup.

hx-post hx-target x-datax-modelx-show@blur

Alpine flags the field after you leave it — not on the first keystroke — and the error clears live as you fix it. The same rules run server-side on submit (the profile form is the full round trip).

<form x-data="{ email: '', touched: false }">
  <input type="email" x-model="email"
    @blur="touched = true"/>
  <p x-show="touched && email
      && !email.includes('@')">
    That doesn't look like an email.
  </p>
</form>
// Server-side mirror of the client rules —
// see ProfileUpdate for the full pattern:
// errors map rendered at 422 into the form.

Optimistic UI

A favorite toggle that swaps instantly — current state rides along in hx-vals, so the endpoint stays stateless.

hx-post hx-swap="outerHTML" hx-vals x-transition
<button hx-post="/patterns/api/favorite/1"
  hx-vals={ favoritedState }
  hx-swap="outerHTML" x-transition>
  &#9734; Favorite
</button>
favorited := r.FormValue("favorited") == "true"
props := partials.PatternFavoriteProps{
  ID: id, Favorited: !favorited}
view.Render(w, r, http.StatusOK,
  partials.PatternFavoriteButton(props))

Bulk operations

Alpine tracks the checkbox selection client-side; one HTMX POST submits the whole batch as form values.

hx-post hx-target x-datax-modelx-text
<form x-data="{ selected: [] }"
  hx-post="/patterns/api/bulk"
  hx-target="#bulk-demo-result">
  <input type="checkbox" name="selected"
    x-model="selected"/>
  <button :disabled="selected.length === 0">
    Archive (<span x-text="selected.length"/>)
  </button>
</form>
r.ParseForm()
count := len(r.Form["selected"])
view.Render(w, r, http.StatusOK,
  partials.PatternBulkResult(count))

Confirm + disabled button

The htmx:confirm event pauses the request while a styled Alpine dialog asks — no native window.confirm popup — and hx-disabled-elt holds the button while it runs. hx-confirm stays on as the no-Alpine fallback.

htmx:confirm hx-confirm hx-disabled-elt="this" x-datax-teleportx-show
<div x-data="{ open: false, issue: null }"
  @htmx:confirm.prevent="issue =
    $event.detail.issueRequest; open = true">
  <button hx-post="/patterns/api/confirm"
    hx-confirm="Delete this demo item?"
    hx-disabled-elt="this">Delete item</button>
  <!-- styled dialog; Delete resumes with
       issue(true), Cancel just closes -->
</div>
// The interesting part happens client-side
// before this runs: htmx:confirm pauses the
// request until issueRequest(true) resumes it.
view.Render(w, r, http.StatusOK,
  partials.PatternConfirmResult())

Loading states

The right way to show in-flight work: a per-element spinner (hx-indicator) and a disabled trigger (hx-disabled-elt) — never a full-screen overlay.

hx-indicator hx-disabled-elt="this"
Working...
<button hx-get="/patterns/api/slow"
  hx-indicator="#slow-spin"
  hx-disabled-elt="this"
  hx-target="#slow-out">Load slowly</button>
<span id="slow-spin" class="htmx-indicator">
  Working...
</span>
// Deliberate delay so there is something
// to indicate (capped at 1.5s).
time.Sleep(delay)
view.Render(w, r, http.StatusOK,
  partials.PatternSlowContent())

Server-driven UX

Responses that steer the page: toasts from headers, multi-region swaps, animated transitions — and an honest 429 when the server says no.

Toast notifications

The response body swaps a tiny status line; the toast itself fires from the HX-Trigger header into an Alpine listener.

HX-Trigger HX-Toast-Type x-datax-forx-transition
<button hx-post="/patterns/api/toast"
  hx-vals={ toastType }>Success toast</button>
<!-- base layout listens for HX-Trigger
     and dispatches a toast event -->
view.SetHXTrigger(w, "Toast from the server!")
w.Header().Set("HX-Toast-Type", toastType)
view.Render(w, r, http.StatusOK,
  partials.PatternToastResult())

Out-of-band swap

One response updates two regions: the button replaces itself, and a badge elsewhere updates via hx-swap-oob riding the same payload.

hx-swap-oob hx-vals hx-swap="outerHTML"
0 added

The badge is a separate region — it updates via hx-swap-oob in the button's response.

<!-- response contains BOTH: -->
<button id="oob-counter-btn" ...>Add item</button>
<span id="oob-total" hx-swap-oob="true">
  3 added
</span>
count, _ := strconv.Atoi(r.FormValue("count"))
view.Render(w, r, http.StatusOK,
  partials.PatternCounterResponse(
    partials.PatternCounterProps{Count: count + 1}))

View Transitions

transition:true on the swap animates it through the browser's View Transitions API — cross-fade with zero animation code, instant fallback elsewhere.

hx-swap="innerHTML transition:true"

Teal

Swapped with a cross-fade via the View Transitions API - no animation library.

<button hx-get="/patterns/api/transition?step=1"
  hx-target="#vt-card"
  hx-swap="innerHTML transition:true">
  Next card
</button>
step, _ := strconv.Atoi(r.URL.Query().Get("step"))
view.Render(w, r, http.StatusOK,
  partials.PatternTransitionCard(step))

Rate limit: 429 with Retry-After

Click faster than the tier allows and the production limiter refuses — a real 429 with Retry-After, rendered instead of swallowed. Same middleware as the auth and /learn tiers, tighter numbers.

hx-swap="beforeend" htmx:beforeSwap Retry-After
Tier: 3 requests, then one every 2s, per client IP. Click fast.
    <button hx-get="/patterns/api/limited"
      hx-target="#limit-log" hx-swap="beforeend">
      Hit the tier
    </button>
    <ol id="limit-log"></ol>
    <!-- app.js: swap 429s that carry HTML -->
    api.With(mw.RateLimiterWith(ctx, 0.5, 3,
      http.HandlerFunc(PatternLimitRefused))).
      Get("/limited", PatternLimitAllowed)
    
    // the limiter sets Retry-After, then:
    func PatternLimitRefused(w, r) {
      view.Render(w, r, http.StatusTooManyRequests,
        partials.PatternLimitRefused(
          w.Header().Get("Retry-After")))
    }

    Alpine islands

    The client-only slice: state, teleported modals, and stores shared across components.

    Dark mode

    Alpine state seeded from the system preference, persisted to localStorage, applied as a class on the root element.

    x-data$watchlocalStorage

    The toggle in the header is this pattern: Alpine state persisted to localStorage, seeded from the system preference, applied as a class on the root element. This panel restyles with it — try it.

    <body x-data="{ dark: localStorage.getItem('dark')
        === 'true' || prefersDark }"
      x-init="$watch('dark', v => ...)">
    // No handler — this pattern is entirely
    // client-side; the server ships one class
    // toggle and no JavaScript framework.

    Global store

    Alpine.store shares state across unrelated components: two separate x-data islands read and write one store registered in app.js.

    Alpine.store$storex-text

    Two unrelated x-data islands sharing one Alpine.store registered in app.js.

    <!-- two unrelated components: -->
    <button @click="$store.demo.inc()">+1</button>
    ...
    <span x-text="$store.demo.count"></span>
    // app.js, at alpine:init:
    Alpine.store("demo", {
      count: 0,
      inc() { this.count++ },
    })